From d000b6d6ba85415d0f67c390318b2c52f422a182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leif=20Roger=20Fr=C3=B8ysaa?= Date: Mon, 8 Jun 2026 14:07:06 +0200 Subject: [PATCH 1/3] fix(auth): preserve API-key user across RequireAuth re-dispatch Open-API (/rest) requests authenticated with a valid API key were rejected with 401 "unauthorized" whenever AUTH_ENABLED is on (the default). The API-key middleware authenticates the request and sets common.USER via c.Set, then re-dispatches the rewritten path through router.HandleContext. gin's HandleContext calls Context.reset(), which sets c.Keys = nil and destroys the user, so the terminal RequireAuth gate sees no user and 401s. Carry the authenticated user on the request's context.Context, which survives HandleContext (only gin Keys are reset). GetUser now falls back to the request context when gin Keys are empty. This fixes every /rest endpoint (webhooks, push, etc.) at once without disabling auth. Add regression coverage: - shared: user set via SetUserOnRequest survives a HandleContext re-dispatch while gin Keys do not. - api: the full RestAuthentication -> OIDCAuthentication -> RequireAuth chain with AUTH_ENABLED=true returns 200 for a valid key, 401 for missing token, 403 for an invalid key, and 401 for a userless protected route. --- backend/server/api/middlewares.go | 3 +- .../server/api/middlewares_authchain_test.go | 161 ++++++++++++++++++ backend/server/api/shared/gin_utils.go | 35 +++- backend/server/api/shared/gin_utils_test.go | 97 +++++++++++ 4 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 backend/server/api/middlewares_authchain_test.go create mode 100644 backend/server/api/shared/gin_utils_test.go diff --git a/backend/server/api/middlewares.go b/backend/server/api/middlewares.go index a988c41288a..7d4612fe064 100644 --- a/backend/server/api/middlewares.go +++ b/backend/server/api/middlewares.go @@ -33,6 +33,7 @@ import ( "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/common" "github.com/apache/incubator-devlake/helpers/apikeyhelper" + "github.com/apache/incubator-devlake/server/api/shared" "github.com/gin-gonic/gin" ) @@ -272,7 +273,7 @@ func CheckAuthorizationHeader(c *gin.Context, logger log.Logger, db dal.Dal, api logger.Info("redirect path: %s to: %s", c.Request.URL.Path, path) c.Request.URL.Path = path - c.Set(common.USER, &common.User{ + shared.SetUserOnRequest(c, &common.User{ Name: apiKey.Creator.Creator, Email: apiKey.Creator.CreatorEmail, }) diff --git a/backend/server/api/middlewares_authchain_test.go b/backend/server/api/middlewares_authchain_test.go new file mode 100644 index 00000000000..1a99bdd8bc5 --- /dev/null +++ b/backend/server/api/middlewares_authchain_test.go @@ -0,0 +1,161 @@ +/* +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 ( + stdctx "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + corectx "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + contextimpl "github.com/apache/incubator-devlake/impls/context" + "github.com/apache/incubator-devlake/impls/logruslog" + "github.com/apache/incubator-devlake/server/api/auth" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" +) + +// authChainStubDal is a minimal dal.Dal sufficient to drive the auth chain +// without a real database. Only the three methods exercised by this test are +// implemented; any other call would (intentionally) panic via the embedded +// nil dal.Dal, surfacing an unexpected dependency. +type authChainStubDal struct { + dal.Dal + firstErr errors.Error // returned by First; nil means "found" +} + +// All backs the auth service's revocation-cache boot load. Returning no rows +// keeps the service from depending on a real DB. +func (s *authChainStubDal) All(dst interface{}, _ ...dal.Clause) errors.Error { return nil } + +// First backs apikeyhelper.GetApiKey. On success it leaves dst as the zero +// ApiKey, whose empty AllowedPath regex matches every path — enough to model +// "a valid, correctly-scoped key" without persisting one. +func (s *authChainStubDal) First(dst interface{}, _ ...dal.Clause) errors.Error { return s.firstErr } + +func (s *authChainStubDal) IsErrorNotFound(err error) bool { return err != nil } + +// newAuthChainEnv builds a router whose middleware chain mirrors production +// with AUTH_ENABLED=true and OIDC_ENABLED=false (the hardened-image default): +// +// RestAuthentication -> OIDCAuthentication -> RequireAuth +// +// firstErr controls how the stubbed API-key lookup resolves. +func newAuthChainEnv(t *testing.T, firstErr errors.Error) *gin.Engine { + t.Helper() + // apikeyhelper reads ENCRYPTION_SECRET from the global config (AutomaticEnv). + t.Setenv("ENCRYPTION_SECRET", strings.Repeat("a", 32)) + + cfg := viper.New() + cfg.Set("ENCRYPTION_SECRET", strings.Repeat("a", 32)) + cfg.Set("AUTH_ENABLED", true) + cfg.Set("OIDC_ENABLED", false) + + db := &authChainStubDal{firstErr: firstErr} + basicRes := contextimpl.NewDefaultBasicRes(cfg, logruslog.Global, db) + + ctx, cancel := stdctx.WithCancel(stdctx.Background()) + t.Cleanup(cancel) + svc, err := auth.NewService(ctx, basicRes) + if err != nil { + t.Fatalf("auth.NewService: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RestAuthentication(router, basicRes)) // API-key auth for /rest, then re-dispatches + router.Use(svc.OIDCAuthentication()) // session cookie -> sets user (no-op here) + router.Use(svc.RequireAuth()) // terminal gate: no user -> 401 + + // Open-API handler, reached only after RestAuthentication rewrites the + // /rest-prefixed path and re-dispatches through the chain. + router.POST("/plugins/webhook/connections/:id/deployments", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + // A normal protected route used to prove RequireAuth is actually active. + router.GET("/plugins/webhook/connections", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + return router +} + +// TestAuthChainHoldsForApiKeyWithOIDCEnabled is the regression test for the +// open-API 401 bug. With AUTH_ENABLED on and the OIDC RequireAuth gate live, a +// valid API key against a /rest endpoint must survive the internal +// HandleContext re-dispatch (which wipes gin Keys) and reach the handler. +func TestAuthChainHoldsForApiKeyWithOIDCEnabled(t *testing.T) { + const restPath = "/rest/plugins/webhook/connections/1/deployments" + + t.Run("valid key reaches open-api handler (200)", func(t *testing.T) { + router := newAuthChainEnv(t, nil) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, restPath, strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer valid-key") + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (RequireAuth wiped the API-key user)", resp.Code, http.StatusOK) + } + }) + + t.Run("missing token (401)", func(t *testing.T) { + router := newAuthChainEnv(t, nil) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, restPath, strings.NewReader(`{}`)) + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusUnauthorized) + } + if !strings.Contains(resp.Body.String(), "token is missing") { + t.Errorf("body = %q, want it to mention 'token is missing'", resp.Body.String()) + } + }) + + t.Run("invalid key (403)", func(t *testing.T) { + router := newAuthChainEnv(t, errors.NotFound.New("not found")) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, restPath, strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer wrong-key") + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusForbidden) + } + if !strings.Contains(resp.Body.String(), "api key is invalid") { + t.Errorf("body = %q, want it to mention 'api key is invalid'", resp.Body.String()) + } + }) + + t.Run("protected route without user is gated (401)", func(t *testing.T) { + router := newAuthChainEnv(t, nil) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/plugins/webhook/connections", nil) + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d (RequireAuth not active?)", resp.Code, http.StatusUnauthorized) + } + }) +} + +var _ corectx.BasicRes = (*contextimpl.DefaultBasicRes)(nil) diff --git a/backend/server/api/shared/gin_utils.go b/backend/server/api/shared/gin_utils.go index 892dc489ae6..8cbe144fbac 100644 --- a/backend/server/api/shared/gin_utils.go +++ b/backend/server/api/shared/gin_utils.go @@ -18,15 +18,40 @@ limitations under the License. package shared import ( + "context" + "github.com/apache/incubator-devlake/core/models/common" "github.com/gin-gonic/gin" ) +// userContextKey is a dedicated, unexported type for storing the +// authenticated user on the request's context.Context. A dedicated type +// avoids collisions with other packages and satisfies go vet (which flags +// basic types such as plain strings used as context keys). +type userContextKey struct{} + +// SetUserOnRequest stores the authenticated user on the request's +// context.Context so the identity survives gin's Engine.HandleContext +// re-dispatch, which calls Context.reset() and wipes gin's Keys (where +// c.Set stores values). Callers that re-dispatch (e.g. the /rest open-API +// path rewrite) must use this so the terminal RequireAuth gate can still +// see the user. It also mirrors the value into gin Keys via c.Set for the +// common, non-re-dispatched case. +func SetUserOnRequest(c *gin.Context, user *common.User) { + c.Set(common.USER, user) + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userContextKey{}, user)) +} + func GetUser(c *gin.Context) (*common.User, bool) { - userObj, exist := c.Get(common.USER) - if !exist { - return nil, false + if userObj, exist := c.Get(common.USER); exist { + if user, ok := userObj.(*common.User); ok { + return user, true + } + } + // Fall back to the request context, which survives Engine.HandleContext + // re-dispatch (unlike gin Keys, which Context.reset() clears). + if user, ok := c.Request.Context().Value(userContextKey{}).(*common.User); ok { + return user, true } - user := userObj.(*common.User) - return user, true + return nil, false } diff --git a/backend/server/api/shared/gin_utils_test.go b/backend/server/api/shared/gin_utils_test.go new file mode 100644 index 00000000000..09bfc7465f9 --- /dev/null +++ b/backend/server/api/shared/gin_utils_test.go @@ -0,0 +1,97 @@ +/* +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 shared + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/gin-gonic/gin" +) + +// TestGetUserReadsGinKeys covers the common, non-re-dispatched case where the +// user is set directly on the gin context. +func TestGetUserReadsGinKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Set(common.USER, &common.User{Name: "alice"}) + + user, ok := GetUser(c) + if !ok || user == nil || user.Name != "alice" { + t.Fatalf("GetUser() = %+v, %v; want alice, true", user, ok) + } +} + +// TestSetUserOnRequestSurvivesHandleContext is the regression test for the +// open-API key 401 bug: gin's Engine.HandleContext re-dispatch calls +// Context.reset(), which sets c.Keys = nil and therefore drops anything set +// via c.Set. The authenticated user must instead ride on the request's +// context.Context so the terminal RequireAuth gate can still see it after the +// /rest path-rewrite re-dispatch. +func TestSetUserOnRequestSurvivesHandleContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + var ( + ginKeysVisible bool + requestVisible bool + reachedTerminal bool + ) + + router := gin.New() + router.GET("/target", func(c *gin.Context) { + reachedTerminal = true + // gin Keys are wiped by reset() during HandleContext re-dispatch. + if _, ok := c.Get(common.USER); ok { + ginKeysVisible = true + } + // The user must survive on the request context. + if user, ok := GetUser(c); ok && user != nil && user.Name == "bob" { + requestVisible = true + } + c.Status(http.StatusOK) + }) + + // Entry handler mimics RestAuthentication: authenticate, stash the user, + // rewrite the path, and re-dispatch through the engine. + router.GET("/rest/target", func(c *gin.Context) { + SetUserOnRequest(c, &common.User{Name: "bob"}) + c.Request.URL.Path = "/target" + router.HandleContext(c) + c.Abort() + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/rest/target", nil) + router.ServeHTTP(resp, req) + + if !reachedTerminal { + t.Fatal("re-dispatched request never reached the terminal handler") + } + if ginKeysVisible { + t.Error("gin Keys unexpectedly survived HandleContext; test no longer exercises the reset() bug") + } + if !requestVisible { + t.Error("user did not survive HandleContext re-dispatch via request context (regression)") + } + if resp.Code != http.StatusOK { + t.Errorf("status = %d, want %d", resp.Code, http.StatusOK) + } +} From c0eb476841ac3e8812e9b72cb21fc0789a6ddcce Mon Sep 17 00:00:00 2001 From: Jarek <74616204+jaroslawgajewski@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:30:19 +0200 Subject: [PATCH 2/3] feat(gh-copilot): close Copilot metrics parity gaps (#8889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gh-copilot): close API gaps for per-user metrics, teams, CLI, code review, and PR fields Add missing GitHub Copilot Metrics API fields to achieve full API parity: Enterprise/Org metrics: - CLI active user counts and CLI breakdown (sessions, requests, tokens) - Code review user counts (daily/weekly/monthly × active/passive) - Chat panel mode breakdown (agent/ask/custom/edit/plan/unknown) - Expanded PR metrics (merged, merge time, suggestions, Copilot impact) Per-user metrics: - used_cli, used_copilot_code_review_active/passive boolean flags - CLI breakdown per user (sessions, requests, tokens) User-team mapping (new): - New collector/extractor for user-teams-1-day endpoint - Enables team-level metrics via JOIN with per-user tables Seat assignments: - Team assignment fields (assigning_team id/name/slug) - User detail fields (name, email) Includes migration script 20260527 and comprehensive docs in COPILOT_METRICS_GAPS.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(copilot-metrics): remove outdated metrics gaps documentation and implement new user-team mapping and metrics enhancements Signed-off-by: Jarek --------- Signed-off-by: Jarek Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ..._tool_copilot_enterprise_daily_metrics.csv | 6 +- .../snapshot_tables/_tool_copilot_seats.csv | 6 +- .../gh-copilot/models/enterprise_metrics.go | 48 +++++- .../20260527_add_copilot_metrics_gaps.go | 153 ++++++++++++++++++ .../models/migrationscripts/register.go | 1 + backend/plugins/gh-copilot/models/models.go | 2 + .../plugins/gh-copilot/models/models_test.go | 1 + backend/plugins/gh-copilot/models/seat.go | 5 + .../plugins/gh-copilot/models/user_metrics.go | 14 +- .../plugins/gh-copilot/models/user_team.go | 45 ++++++ .../tasks/enterprise_metrics_extractor.go | 100 ++++++++++-- .../gh-copilot/tasks/metrics_extractor.go | 46 ++++++ .../gh-copilot/tasks/org_metrics_collector.go | 1 + backend/plugins/gh-copilot/tasks/register.go | 2 + .../gh-copilot/tasks/seat_extractor.go | 7 + backend/plugins/gh-copilot/tasks/subtasks.go | 17 ++ .../tasks/user_metrics_extractor.go | 41 +++-- .../gh-copilot/tasks/user_teams_collector.go | 133 +++++++++++++++ .../gh-copilot/tasks/user_teams_extractor.go | 93 +++++++++++ 19 files changed, 684 insertions(+), 37 deletions(-) create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260527_add_copilot_metrics_gaps.go create mode 100644 backend/plugins/gh-copilot/models/user_team.go create mode 100644 backend/plugins/gh-copilot/tasks/user_teams_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/user_teams_extractor.go 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 index 57e3f362712..7a74a5cc81d 100644 --- 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 @@ -1,3 +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 +connection_id,scope_id,day,enterprise_id,daily_active_users,weekly_active_users,monthly_active_users,monthly_active_chat_users,monthly_active_agent_users,daily_active_cli_users,daily_active_copilot_code_review_users,daily_passive_copilot_code_review_users,weekly_active_copilot_code_review_users,weekly_passive_copilot_code_review_users,monthly_active_copilot_code_review_users,monthly_passive_copilot_code_review_users,chat_panel_agent_mode,chat_panel_ask_mode,chat_panel_custom_mode,chat_panel_edit_mode,chat_panel_plan_mode,chat_panel_unknown_mode,pr_total_reviewed,pr_total_created,pr_total_merged,pr_median_minutes_to_merge,pr_total_suggestions,pr_total_applied_suggestions,pr_total_created_by_copilot,pr_total_reviewed_by_copilot,pr_total_merged_created_by_copilot,pr_total_merged_reviewed_by_copilot,pr_median_min_to_merge_copilot_authored,pr_median_min_to_merge_copilot_reviewed,pr_total_copilot_suggestions,pr_total_copilot_applied_suggestions,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,cli_session_count,cli_request_count,cli_prompt_count,cli_output_token_sum,cli_prompt_token_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,0,0,0,0,0,0,0,0,0,0,0,0,0,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,0,0,0,0,0,0,0,0,0,0,0,0,0,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_seats.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_seats.csv index 87b28f6b8fb..291a98ca607 100644 --- 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 @@ -1,3 +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 +connection_id,organization,user_login,user_id,user_name,user_email,plan_type,assigning_team_id,assigning_team_name,assigning_team_slug,created_at,last_activity_at,last_activity_editor,last_authenticated_at,pending_cancellation_date,updated_at +1,octodemo,nathos,4215,,,enterprise,0,,,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,0,,,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/models/enterprise_metrics.go b/backend/plugins/gh-copilot/models/enterprise_metrics.go index 07663aa6dd5..967e3ecd326 100644 --- a/backend/plugins/gh-copilot/models/enterprise_metrics.go +++ b/backend/plugins/gh-copilot/models/enterprise_metrics.go @@ -44,6 +44,15 @@ type CopilotCodeMetrics struct { LocDeletedSum int `json:"locDeletedSum"` } +// 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"` +} + // GhCopilotEnterpriseDailyMetrics captures daily enterprise-level aggregate Copilot metrics. type GhCopilotEnterpriseDailyMetrics struct { ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` @@ -57,12 +66,43 @@ type GhCopilotEnterpriseDailyMetrics struct { 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"` + // CLI active users + DailyActiveCliUsers int `json:"dailyActiveCliUsers" gorm:"comment:Daily active CLI users"` + + // Code review user counts + DailyActiveCopilotCodeReviewUsers int `json:"dailyActiveCopilotCodeReviewUsers"` + DailyPassiveCopilotCodeReviewUsers int `json:"dailyPassiveCopilotCodeReviewUsers"` + WeeklyActiveCopilotCodeReviewUsers int `json:"weeklyActiveCopilotCodeReviewUsers"` + WeeklyPassiveCopilotCodeReviewUsers int `json:"weeklyPassiveCopilotCodeReviewUsers"` + MonthlyActiveCopilotCodeReviewUsers int `json:"monthlyActiveCopilotCodeReviewUsers"` + MonthlyPassiveCopilotCodeReviewUsers int `json:"monthlyPassiveCopilotCodeReviewUsers"` + + // Chat panel mode breakdown + ChatPanelAgentMode int `json:"chatPanelAgentMode" gorm:"comment:Chat panel agent mode interactions"` + ChatPanelAskMode int `json:"chatPanelAskMode" gorm:"comment:Chat panel ask mode interactions"` + ChatPanelCustomMode int `json:"chatPanelCustomMode" gorm:"comment:Chat panel custom mode interactions"` + ChatPanelEditMode int `json:"chatPanelEditMode" gorm:"comment:Chat panel edit mode interactions"` + ChatPanelPlanMode int `json:"chatPanelPlanMode" gorm:"comment:Chat panel plan mode interactions"` + ChatPanelUnknownMode int `json:"chatPanelUnknownMode" gorm:"comment:Chat panel unknown mode interactions"` + + // Pull request metrics (expanded) + PRTotalReviewed int `json:"prTotalReviewed" gorm:"comment:Total PRs reviewed"` + PRTotalCreated int `json:"prTotalCreated" gorm:"comment:Total PRs created"` + PRTotalMerged int `json:"prTotalMerged" gorm:"comment:Total PRs merged"` + PRMedianMinutesToMerge float64 `json:"prMedianMinutesToMerge" gorm:"comment:Median minutes to merge PRs"` + PRTotalSuggestions int `json:"prTotalSuggestions" gorm:"comment:Total PR review suggestions"` + PRTotalAppliedSuggestions int `json:"prTotalAppliedSuggestions" gorm:"comment:Total applied PR suggestions"` + PRTotalCreatedByCopilot int `json:"prTotalCreatedByCopilot" gorm:"comment:PRs created by Copilot"` + PRTotalReviewedByCopilot int `json:"prTotalReviewedByCopilot" gorm:"comment:PRs reviewed by Copilot"` + PRTotalMergedCreatedByCopilot int `json:"prTotalMergedCreatedByCopilot" gorm:"comment:Merged PRs created by Copilot"` + PRTotalMergedReviewedByCopilot int `json:"prTotalMergedReviewedByCopilot" gorm:"comment:Merged PRs reviewed by Copilot"` + PRMedianMinToMergeCopilotAuthored float64 `json:"prMedianMinToMergeCopilotAuthored" gorm:"comment:Median min to merge Copilot-authored PRs"` + 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"` CopilotActivityMetrics `mapstructure:",squash"` + CopilotCliMetrics `mapstructure:",squash"` common.NoPKModel } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260527_add_copilot_metrics_gaps.go b/backend/plugins/gh-copilot/models/migrationscripts/20260527_add_copilot_metrics_gaps.go new file mode 100644 index 00000000000..f676b0e4bee --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260527_add_copilot_metrics_gaps.go @@ -0,0 +1,153 @@ +/* +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/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +type addCopilotMetricsGaps struct{} + +// --- Enterprise daily metrics: new columns --- + +type enterpriseDailyMetrics20260527 struct { + // CLI + DailyActiveCliUsers int + + // Code review user counts + DailyActiveCopilotCodeReviewUsers int + DailyPassiveCopilotCodeReviewUsers int + WeeklyActiveCopilotCodeReviewUsers int + WeeklyPassiveCopilotCodeReviewUsers int + MonthlyActiveCopilotCodeReviewUsers int + MonthlyPassiveCopilotCodeReviewUsers int + + // Chat panel mode breakdown + ChatPanelAgentMode int + ChatPanelAskMode int + ChatPanelCustomMode int + ChatPanelEditMode int + ChatPanelPlanMode int + ChatPanelUnknownMode int + + // Expanded PR metrics + PRTotalMerged int + PRMedianMinutesToMerge float64 + PRTotalSuggestions int + PRTotalAppliedSuggestions int + PRTotalMergedCreatedByCopilot int + PRTotalMergedReviewedByCopilot int + PRMedianMinToMergeCopilotAuthored float64 + PRMedianMinToMergeCopilotReviewed float64 + PRTotalCopilotSuggestions int + PRTotalCopilotAppliedSuggestions int + + // CLI breakdown + CliSessionCount int + CliRequestCount int + CliPromptCount int + CliOutputTokenSum int + CliPromptTokenSum int +} + +func (enterpriseDailyMetrics20260527) TableName() string { + return "_tool_copilot_enterprise_daily_metrics" +} + +// --- User daily metrics: new columns --- + +type userDailyMetrics20260527 struct { + UsedCli bool + UsedCopilotCodeReviewActive bool + UsedCopilotCodeReviewPassive bool + + // CLI breakdown + CliSessionCount int + CliRequestCount int + CliPromptCount int + CliOutputTokenSum int + CliPromptTokenSum int +} + +func (userDailyMetrics20260527) TableName() string { + return "_tool_copilot_user_daily_metrics" +} + +// --- Seat: new columns --- + +type seat20260527 struct { + UserName string `gorm:"type:varchar(255)"` + UserEmail string `gorm:"type:varchar(255)"` + AssigningTeamId int64 + AssigningTeamName string `gorm:"type:varchar(255)"` + AssigningTeamSlug string `gorm:"type:varchar(255)"` +} + +func (seat20260527) TableName() string { + return "_tool_copilot_seats" +} + +// --- User-teams: new table --- + +type userTeam20260527 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + TeamId int64 `gorm:"primaryKey"` + + UserLogin string `gorm:"type:varchar(255);index"` + OrganizationId string `gorm:"type:varchar(100)"` + EnterpriseId string `gorm:"type:varchar(100)"` + TeamSlug string `gorm:"type:varchar(255)"` + + archived.NoPKModel +} + +func (userTeam20260527) TableName() string { + return "_tool_copilot_user_teams" +} + +func (script *addCopilotMetricsGaps) Up(basicRes context.BasicRes) errors.Error { + // Add new columns to existing tables + if err := migrationhelper.AutoMigrateTables(basicRes, + &enterpriseDailyMetrics20260527{}, + &userDailyMetrics20260527{}, + &seat20260527{}, + ); err != nil { + return err + } + + // Create new user-teams table + return migrationhelper.AutoMigrateTables(basicRes, + &userTeam20260527{}, + ) +} + +func (*addCopilotMetricsGaps) Version() uint64 { + return 20260527000000 +} + +func (*addCopilotMetricsGaps) Name() string { + return "Add Copilot metrics gaps: CLI, code review, chat modes, PR expansion, user-teams" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/register.go b/backend/plugins/gh-copilot/models/migrationscripts/register.go index a9c1a770bfa..399735695e0 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/register.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/register.go @@ -30,5 +30,6 @@ func All() []plugin.MigrationScript { new(migrateToUsageMetricsV2), new(addPRFieldsToEnterpriseMetrics), new(addOrganizationIdToUserMetrics), + new(addCopilotMetricsGaps), } } diff --git a/backend/plugins/gh-copilot/models/models.go b/backend/plugins/gh-copilot/models/models.go index f223c821827..5143ce5f8b7 100644 --- a/backend/plugins/gh-copilot/models/models.go +++ b/backend/plugins/gh-copilot/models/models.go @@ -45,5 +45,7 @@ func GetTablesInfo() []dal.Tabler { &GhCopilotUserMetricsByModelFeature{}, // Seat assignments &GhCopilotSeat{}, + // User-team mappings + &GhCopilotUserTeam{}, } } diff --git a/backend/plugins/gh-copilot/models/models_test.go b/backend/plugins/gh-copilot/models/models_test.go index 8c61d222079..ef5b3eff6f5 100644 --- a/backend/plugins/gh-copilot/models/models_test.go +++ b/backend/plugins/gh-copilot/models/models_test.go @@ -40,6 +40,7 @@ func TestGetTablesInfo(t *testing.T) { (&GhCopilotUserMetricsByLanguageModel{}).TableName(): false, (&GhCopilotUserMetricsByModelFeature{}).TableName(): false, (&GhCopilotSeat{}).TableName(): false, + (&GhCopilotUserTeam{}).TableName(): false, } if len(tables) != len(expected) { diff --git a/backend/plugins/gh-copilot/models/seat.go b/backend/plugins/gh-copilot/models/seat.go index 85ebf177ae4..d65c80f2e30 100644 --- a/backend/plugins/gh-copilot/models/seat.go +++ b/backend/plugins/gh-copilot/models/seat.go @@ -29,7 +29,12 @@ type GhCopilotSeat struct { Organization string `gorm:"primaryKey;type:varchar(255)"` UserLogin string `gorm:"primaryKey;type:varchar(255)"` UserId int64 `gorm:"index"` + UserName string `gorm:"type:varchar(255)" json:"userName"` + UserEmail string `gorm:"type:varchar(255)" json:"userEmail"` PlanType string `gorm:"type:varchar(32)"` + AssigningTeamId int64 `json:"assigningTeamId" gorm:"comment:Team that assigned the seat"` + AssigningTeamName string `json:"assigningTeamName" gorm:"type:varchar(255)"` + AssigningTeamSlug string `json:"assigningTeamSlug" gorm:"type:varchar(255)"` CreatedAt time.Time LastActivityAt *time.Time LastActivityEditor string diff --git a/backend/plugins/gh-copilot/models/user_metrics.go b/backend/plugins/gh-copilot/models/user_metrics.go index 1f17acad80a..18e9134c226 100644 --- a/backend/plugins/gh-copilot/models/user_metrics.go +++ b/backend/plugins/gh-copilot/models/user_metrics.go @@ -30,13 +30,17 @@ type GhCopilotUserDailyMetrics struct { Day time.Time `gorm:"primaryKey;type:date" json:"day"` UserId int64 `gorm:"primaryKey" json:"userId"` - OrganizationId string `json:"organizationId" gorm:"type:varchar(100)"` - 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"` + OrganizationId string `json:"organizationId" gorm:"type:varchar(100)"` + 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"` + 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"` CopilotActivityMetrics `mapstructure:",squash"` + CopilotCliMetrics `mapstructure:",squash"` common.NoPKModel } diff --git a/backend/plugins/gh-copilot/models/user_team.go b/backend/plugins/gh-copilot/models/user_team.go new file mode 100644 index 00000000000..d04d55ac56f --- /dev/null +++ b/backend/plugins/gh-copilot/models/user_team.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" +) + +// GhCopilotUserTeam maps users to teams per day from the user-teams-1-day report. +// This enables team-level metrics aggregation by joining with per-user daily metrics. +type GhCopilotUserTeam 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"` + TeamId int64 `gorm:"primaryKey" json:"teamId"` + + UserLogin string `json:"userLogin" gorm:"type:varchar(255);index"` + OrganizationId string `json:"organizationId" gorm:"type:varchar(100)"` + EnterpriseId string `json:"enterpriseId" gorm:"type:varchar(100)"` + TeamSlug string `json:"teamSlug" gorm:"type:varchar(255)"` + + common.NoPKModel +} + +func (GhCopilotUserTeam) TableName() string { + return "_tool_copilot_user_teams" +} diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go index 8686b8cc415..e98a3c4f0e5 100644 --- a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go @@ -30,13 +30,31 @@ import ( // --- Enterprise report JSON structures --- 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"` + 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"` + DailyActiveCliUsers int `json:"daily_active_cli_users"` + + // Code review user counts + DailyActiveCopilotCodeReviewUsers int `json:"daily_active_copilot_code_review_users"` + DailyPassiveCopilotCodeReviewUsers int `json:"daily_passive_copilot_code_review_users"` + WeeklyActiveCopilotCodeReviewUsers int `json:"weekly_active_copilot_code_review_users"` + WeeklyPassiveCopilotCodeReviewUsers int `json:"weekly_passive_copilot_code_review_users"` + MonthlyActiveCopilotCodeReviewUsers int `json:"monthly_active_copilot_code_review_users"` + MonthlyPassiveCopilotCodeReviewUsers int `json:"monthly_passive_copilot_code_review_users"` + + // Chat panel mode breakdown + ChatPanelAgentMode int `json:"chat_panel_agent_mode"` + ChatPanelAskMode int `json:"chat_panel_ask_mode"` + ChatPanelCustomMode int `json:"chat_panel_custom_mode"` + ChatPanelEditMode int `json:"chat_panel_edit_mode"` + ChatPanelPlanMode int `json:"chat_panel_plan_mode"` + ChatPanelUnknownMode int `json:"chat_panel_unknown_mode"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` CodeGenerationActivityCount int `json:"code_generation_activity_count"` CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` @@ -49,6 +67,7 @@ type enterpriseDayTotal struct { 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"` PullRequests *pullRequestStats `json:"pull_requests"` } @@ -97,10 +116,32 @@ type totalsByLangModel struct { } 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"` + TotalReviewed int `json:"total_reviewed"` + TotalCreated int `json:"total_created"` + TotalMerged int `json:"total_merged"` + MedianMinutesToMerge float64 `json:"median_minutes_to_merge"` + TotalSuggestions int `json:"total_suggestions"` + TotalAppliedSuggestions int `json:"total_applied_suggestions"` + TotalCreatedByCopilot int `json:"total_created_by_copilot"` + TotalReviewedByCopilot int `json:"total_reviewed_by_copilot"` + TotalMergedCreatedByCopilot int `json:"total_merged_created_by_copilot"` + TotalMergedReviewedByCopilot int `json:"total_merged_reviewed_by_copilot"` + MedianMinToMergeCopilotAuthored float64 `json:"median_minutes_to_merge_copilot_authored"` + MedianMinToMergeCopilotReviewed float64 `json:"median_minutes_to_merge_copilot_reviewed"` + TotalCopilotSuggestions int `json:"total_copilot_suggestions"` + TotalCopilotAppliedSuggestions int `json:"total_copilot_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"` +} + +type cliTokens struct { + OutputTokensSum int `json:"output_tokens_sum"` + PromptTokensSum int `json:"prompt_tokens_sum"` } type totalsByModelFeature struct { @@ -167,6 +208,22 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { MonthlyActiveUsers: dt.MonthlyActiveUsers, MonthlyActiveChatUsers: dt.MonthlyActiveChatUsers, MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, + DailyActiveCliUsers: dt.DailyActiveCliUsers, + + DailyActiveCopilotCodeReviewUsers: dt.DailyActiveCopilotCodeReviewUsers, + DailyPassiveCopilotCodeReviewUsers: dt.DailyPassiveCopilotCodeReviewUsers, + WeeklyActiveCopilotCodeReviewUsers: dt.WeeklyActiveCopilotCodeReviewUsers, + WeeklyPassiveCopilotCodeReviewUsers: dt.WeeklyPassiveCopilotCodeReviewUsers, + MonthlyActiveCopilotCodeReviewUsers: dt.MonthlyActiveCopilotCodeReviewUsers, + MonthlyPassiveCopilotCodeReviewUsers: dt.MonthlyPassiveCopilotCodeReviewUsers, + + ChatPanelAgentMode: dt.ChatPanelAgentMode, + ChatPanelAskMode: dt.ChatPanelAskMode, + ChatPanelCustomMode: dt.ChatPanelCustomMode, + ChatPanelEditMode: dt.ChatPanelEditMode, + ChatPanelPlanMode: dt.ChatPanelPlanMode, + ChatPanelUnknownMode: dt.ChatPanelUnknownMode, + CopilotActivityMetrics: models.CopilotActivityMetrics{ UserInitiatedInteractionCount: dt.UserInitiatedInteractionCount, CodeGenerationActivityCount: dt.CodeGenerationActivityCount, @@ -177,11 +234,32 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { LocDeletedSum: dt.LocDeletedSum, }, } + if dt.TotalsByCli != nil { + dailyMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ + CliSessionCount: dt.TotalsByCli.SessionCount, + CliRequestCount: dt.TotalsByCli.RequestCount, + CliPromptCount: dt.TotalsByCli.PromptCount, + } + if dt.TotalsByCli.TokenUsage != nil { + dailyMetrics.CopilotCliMetrics.CliOutputTokenSum = dt.TotalsByCli.TokenUsage.OutputTokensSum + dailyMetrics.CopilotCliMetrics.CliPromptTokenSum = dt.TotalsByCli.TokenUsage.PromptTokensSum + } + } if dt.PullRequests != nil { dailyMetrics.PRTotalReviewed = dt.PullRequests.TotalReviewed dailyMetrics.PRTotalCreated = dt.PullRequests.TotalCreated + dailyMetrics.PRTotalMerged = dt.PullRequests.TotalMerged + dailyMetrics.PRMedianMinutesToMerge = dt.PullRequests.MedianMinutesToMerge + dailyMetrics.PRTotalSuggestions = dt.PullRequests.TotalSuggestions + dailyMetrics.PRTotalAppliedSuggestions = dt.PullRequests.TotalAppliedSuggestions dailyMetrics.PRTotalCreatedByCopilot = dt.PullRequests.TotalCreatedByCopilot dailyMetrics.PRTotalReviewedByCopilot = dt.PullRequests.TotalReviewedByCopilot + dailyMetrics.PRTotalMergedCreatedByCopilot = dt.PullRequests.TotalMergedCreatedByCopilot + dailyMetrics.PRTotalMergedReviewedByCopilot = dt.PullRequests.TotalMergedReviewedByCopilot + dailyMetrics.PRMedianMinToMergeCopilotAuthored = dt.PullRequests.MedianMinToMergeCopilotAuthored + dailyMetrics.PRMedianMinToMergeCopilotReviewed = dt.PullRequests.MedianMinToMergeCopilotReviewed + dailyMetrics.PRTotalCopilotSuggestions = dt.PullRequests.TotalCopilotSuggestions + dailyMetrics.PRTotalCopilotAppliedSuggestions = dt.PullRequests.TotalCopilotAppliedSuggestions } results = append(results, dailyMetrics) diff --git a/backend/plugins/gh-copilot/tasks/metrics_extractor.go b/backend/plugins/gh-copilot/tasks/metrics_extractor.go index 4d635c1723e..d89eababde6 100644 --- a/backend/plugins/gh-copilot/tasks/metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/metrics_extractor.go @@ -38,12 +38,21 @@ type copilotSeatResponse struct { LastActivityAt *string `json:"last_activity_at"` LastActivityEditor string `json:"last_activity_editor"` Assignee copilotAssignee `json:"assignee"` + AssigningTeam *copilotTeam `json:"assigning_team"` } type copilotAssignee struct { Login string `json:"login"` Id int64 `json:"id"` Type string `json:"type"` + Name string `json:"name"` + Email string `json:"email"` +} + +type copilotTeam struct { + Id int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` } // ExtractOrgMetrics parses org report data from the new report download API. @@ -100,6 +109,22 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { MonthlyActiveUsers: dt.MonthlyActiveUsers, MonthlyActiveChatUsers: dt.MonthlyActiveChatUsers, MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, + DailyActiveCliUsers: dt.DailyActiveCliUsers, + + DailyActiveCopilotCodeReviewUsers: dt.DailyActiveCopilotCodeReviewUsers, + DailyPassiveCopilotCodeReviewUsers: dt.DailyPassiveCopilotCodeReviewUsers, + WeeklyActiveCopilotCodeReviewUsers: dt.WeeklyActiveCopilotCodeReviewUsers, + WeeklyPassiveCopilotCodeReviewUsers: dt.WeeklyPassiveCopilotCodeReviewUsers, + MonthlyActiveCopilotCodeReviewUsers: dt.MonthlyActiveCopilotCodeReviewUsers, + MonthlyPassiveCopilotCodeReviewUsers: dt.MonthlyPassiveCopilotCodeReviewUsers, + + ChatPanelAgentMode: dt.ChatPanelAgentMode, + ChatPanelAskMode: dt.ChatPanelAskMode, + ChatPanelCustomMode: dt.ChatPanelCustomMode, + ChatPanelEditMode: dt.ChatPanelEditMode, + ChatPanelPlanMode: dt.ChatPanelPlanMode, + ChatPanelUnknownMode: dt.ChatPanelUnknownMode, + CopilotActivityMetrics: models.CopilotActivityMetrics{ UserInitiatedInteractionCount: dt.UserInitiatedInteractionCount, CodeGenerationActivityCount: dt.CodeGenerationActivityCount, @@ -110,11 +135,32 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { LocDeletedSum: dt.LocDeletedSum, }, } + if dt.TotalsByCli != nil { + dailyMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ + CliSessionCount: dt.TotalsByCli.SessionCount, + CliRequestCount: dt.TotalsByCli.RequestCount, + CliPromptCount: dt.TotalsByCli.PromptCount, + } + if dt.TotalsByCli.TokenUsage != nil { + dailyMetrics.CopilotCliMetrics.CliOutputTokenSum = dt.TotalsByCli.TokenUsage.OutputTokensSum + dailyMetrics.CopilotCliMetrics.CliPromptTokenSum = dt.TotalsByCli.TokenUsage.PromptTokensSum + } + } if dt.PullRequests != nil { dailyMetrics.PRTotalReviewed = dt.PullRequests.TotalReviewed dailyMetrics.PRTotalCreated = dt.PullRequests.TotalCreated + dailyMetrics.PRTotalMerged = dt.PullRequests.TotalMerged + dailyMetrics.PRMedianMinutesToMerge = dt.PullRequests.MedianMinutesToMerge + dailyMetrics.PRTotalSuggestions = dt.PullRequests.TotalSuggestions + dailyMetrics.PRTotalAppliedSuggestions = dt.PullRequests.TotalAppliedSuggestions dailyMetrics.PRTotalCreatedByCopilot = dt.PullRequests.TotalCreatedByCopilot dailyMetrics.PRTotalReviewedByCopilot = dt.PullRequests.TotalReviewedByCopilot + dailyMetrics.PRTotalMergedCreatedByCopilot = dt.PullRequests.TotalMergedCreatedByCopilot + dailyMetrics.PRTotalMergedReviewedByCopilot = dt.PullRequests.TotalMergedReviewedByCopilot + dailyMetrics.PRMedianMinToMergeCopilotAuthored = dt.PullRequests.MedianMinToMergeCopilotAuthored + dailyMetrics.PRMedianMinToMergeCopilotReviewed = dt.PullRequests.MedianMinToMergeCopilotReviewed + dailyMetrics.PRTotalCopilotSuggestions = dt.PullRequests.TotalCopilotSuggestions + dailyMetrics.PRTotalCopilotAppliedSuggestions = dt.PullRequests.TotalCopilotAppliedSuggestions } results = append(results, dailyMetrics) diff --git a/backend/plugins/gh-copilot/tasks/org_metrics_collector.go b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go index 8f651c4821f..c3a8b5e4409 100644 --- a/backend/plugins/gh-copilot/tasks/org_metrics_collector.go +++ b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go @@ -20,6 +20,7 @@ package tasks import ( "encoding/json" "fmt" + "io" "net/http" "net/url" "time" diff --git a/backend/plugins/gh-copilot/tasks/register.go b/backend/plugins/gh-copilot/tasks/register.go index ee1dcc797fc..3c7e5b1eeb9 100644 --- a/backend/plugins/gh-copilot/tasks/register.go +++ b/backend/plugins/gh-copilot/tasks/register.go @@ -27,10 +27,12 @@ func GetSubTaskMetas() []plugin.SubTaskMeta { CollectCopilotSeatAssignmentsMeta, CollectEnterpriseMetricsMeta, CollectUserMetricsMeta, + CollectUserTeamsMeta, // Extractors ExtractSeatsMeta, ExtractOrgMetricsMeta, ExtractEnterpriseMetricsMeta, ExtractUserMetricsMeta, + ExtractUserTeamsMeta, } } diff --git a/backend/plugins/gh-copilot/tasks/seat_extractor.go b/backend/plugins/gh-copilot/tasks/seat_extractor.go index 48abc3c0ce1..1a1b6b13518 100644 --- a/backend/plugins/gh-copilot/tasks/seat_extractor.go +++ b/backend/plugins/gh-copilot/tasks/seat_extractor.go @@ -96,6 +96,8 @@ func ExtractSeats(taskCtx plugin.SubTaskContext) errors.Error { Organization: connection.Organization, UserLogin: seat.Assignee.Login, UserId: seat.Assignee.Id, + UserName: seat.Assignee.Name, + UserEmail: seat.Assignee.Email, PlanType: seat.PlanType, CreatedAt: createdAt, LastActivityAt: lastAct, @@ -104,6 +106,11 @@ func ExtractSeats(taskCtx plugin.SubTaskContext) errors.Error { PendingCancellationDate: pendingCancel, UpdatedAt: updatedAt, } + if seat.AssigningTeam != nil { + toolSeat.AssigningTeamId = seat.AssigningTeam.Id + toolSeat.AssigningTeamName = seat.AssigningTeam.Name + toolSeat.AssigningTeamSlug = seat.AssigningTeam.Slug + } return []interface{}{toolSeat}, nil }, diff --git a/backend/plugins/gh-copilot/tasks/subtasks.go b/backend/plugins/gh-copilot/tasks/subtasks.go index 24a2c95f1c5..61ed5799525 100644 --- a/backend/plugins/gh-copilot/tasks/subtasks.go +++ b/backend/plugins/gh-copilot/tasks/subtasks.go @@ -53,6 +53,14 @@ var CollectUserMetricsMeta = plugin.SubTaskMeta{ Description: "Collect GitHub Copilot enterprise user-level usage metrics reports", } +var CollectUserTeamsMeta = plugin.SubTaskMeta{ + Name: "collectUserTeams", + EntryPoint: CollectUserTeams, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot user-team mappings from user-teams-1-day report", +} + var ExtractOrgMetricsMeta = plugin.SubTaskMeta{ Name: "extractOrgMetrics", EntryPoint: ExtractOrgMetrics, @@ -88,3 +96,12 @@ var ExtractUserMetricsMeta = plugin.SubTaskMeta{ Description: "Extract Copilot user metrics into tool-layer tables", Dependencies: []*plugin.SubTaskMeta{&CollectUserMetricsMeta}, } + +var ExtractUserTeamsMeta = plugin.SubTaskMeta{ + Name: "extractUserTeams", + EntryPoint: ExtractUserTeams, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot user-team mappings into tool-layer table", + Dependencies: []*plugin.SubTaskMeta{&CollectUserTeamsMeta}, +} diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go index 96f5570f758..72992194063 100644 --- a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go @@ -46,11 +46,15 @@ type userDailyReport struct { 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"` } type userTotalsByIde struct { @@ -106,16 +110,19 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { 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, - OrganizationId: u.OrganizationId, - EnterpriseId: u.EnterpriseId, - UserLogin: u.UserLogin, - UsedAgent: u.UsedAgent, - UsedChat: u.UsedChat, + userMetrics := &models.GhCopilotUserDailyMetrics{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + OrganizationId: u.OrganizationId, + EnterpriseId: u.EnterpriseId, + UserLogin: u.UserLogin, + UsedAgent: u.UsedAgent, + UsedChat: u.UsedChat, + UsedCli: u.UsedCli, + UsedCopilotCodeReviewActive: u.UsedCopilotCodeReviewActive, + UsedCopilotCodeReviewPassive: u.UsedCopilotCodeReviewPassive, CopilotActivityMetrics: models.CopilotActivityMetrics{ UserInitiatedInteractionCount: u.UserInitiatedInteractionCount, CodeGenerationActivityCount: u.CodeGenerationActivityCount, @@ -125,7 +132,19 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { LocAddedSum: u.LocAddedSum, LocDeletedSum: u.LocDeletedSum, }, - }) + } + if u.TotalsByCli != nil { + userMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ + CliSessionCount: u.TotalsByCli.SessionCount, + CliRequestCount: u.TotalsByCli.RequestCount, + CliPromptCount: u.TotalsByCli.PromptCount, + } + if u.TotalsByCli.TokenUsage != nil { + userMetrics.CopilotCliMetrics.CliOutputTokenSum = u.TotalsByCli.TokenUsage.OutputTokensSum + userMetrics.CopilotCliMetrics.CliPromptTokenSum = u.TotalsByCli.TokenUsage.PromptTokensSum + } + } + results = append(results, userMetrics) // User by IDE for _, ide := range u.TotalsByIde { diff --git a/backend/plugins/gh-copilot/tasks/user_teams_collector.go b/backend/plugins/gh-copilot/tasks/user_teams_collector.go new file mode 100644 index 00000000000..2ae0200d2ef --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/user_teams_collector.go @@ -0,0 +1,133 @@ +/* +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 rawUserTeamsTable = "copilot_user_teams" + +// CollectUserTeams collects user-team mapping data from the user-teams-1-day report. +// This enables team-level metrics aggregation by joining with per-user daily metrics. +func CollectUserTeams(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() + + var urlTemplate string + + if connection.HasEnterprise() { + urlTemplate = fmt.Sprintf("enterprises/%s/copilot/metrics/reports/user-teams-1-day", connection.Enterprise) + } else if connection.Organization != "" { + urlTemplate = fmt.Sprintf("orgs/%s/copilot/metrics/reports/user-teams-1-day", connection.Organization) + } else { + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawUserTeamsTable, + 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: urlTemplate, + 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: ignoreNoContent, + 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") + } + if isEmptyReport(body) { + return nil, nil + } + + 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 + } + // User-teams reports are JSONL format + records, parseErr := parseJSONL(reportBody) + if parseErr != nil { + return nil, parseErr + } + results = append(results, records...) + } + return results, nil + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/user_teams_extractor.go b/backend/plugins/gh-copilot/tasks/user_teams_extractor.go new file mode 100644 index 00000000000..72a3de8abe9 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/user_teams_extractor.go @@ -0,0 +1,93 @@ +/* +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" +) + +// userTeamRecord represents a single line from the user-teams-1-day JSONL report. +type userTeamRecord struct { + Day string `json:"day"` + UserId int64 `json:"user_id"` + UserLogin string `json:"user_login"` + OrganizationId string `json:"organization_id"` + EnterpriseId string `json:"enterprise_id"` + TeamId int64 `json:"team_id"` + Slug string `json:"slug"` +} + +// ExtractUserTeams parses user-team JSONL records into the GhCopilotUserTeam model. +func ExtractUserTeams(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: rawUserTeamsTable, + Options: params, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var rec userTeamRecord + if err := errors.Convert(json.Unmarshal(row.Data, &rec)); err != nil { + return nil, err + } + + day, parseErr := time.Parse("2006-01-02", rec.Day) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid day in user-teams report") + } + + return []interface{}{ + &models.GhCopilotUserTeam{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: rec.UserId, + TeamId: rec.TeamId, + UserLogin: rec.UserLogin, + OrganizationId: rec.OrganizationId, + EnterpriseId: rec.EnterpriseId, + TeamSlug: rec.Slug, + }, + }, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} From 22d449616541b9216e6c543170bd53a957d4b9ff Mon Sep 17 00:00:00 2001 From: Volodymyr Zahorniak <7808206+zahorniak@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:33:13 +0300 Subject: [PATCH 3/3] fix(plugin-circleci): populate workflow id for unfinished-job collection (#8907) (#8912) * refactor(plugin-circleci): extract unfinished-jobs input clauses into a helper Signed-off-by: Volodymyr Zahorniak * fix(plugin-circleci): populate workflow id for unfinished-job collection (#8907) The collectJobs 'unfinished details' collector built its URL from '/v2/workflow/{{ .Input.Id }}/job' but its iterator selected 'DISTINCT workflow_id' into a models.CircleciJob, leaving .Id empty and producing '/v2/workflow//job' (HTTP 500) whenever a job was running/queued/on_hold. Alias the projection to 'workflow_id AS id' so .Id carries the workflow id, mirroring the new-records collector. Adds an e2e regression test. Signed-off-by: Volodymyr Zahorniak --------- Signed-off-by: Volodymyr Zahorniak Co-authored-by: Klesh Wong --- .../circleci/e2e/job_collector_test.go | 82 +++++++++++++++++++ .../plugins/circleci/tasks/job_collector.go | 22 +++-- 2 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 backend/plugins/circleci/e2e/job_collector_test.go diff --git a/backend/plugins/circleci/e2e/job_collector_test.go b/backend/plugins/circleci/e2e/job_collector_test.go new file mode 100644 index 00000000000..ed2f014217f --- /dev/null +++ b/backend/plugins/circleci/e2e/job_collector_test.go @@ -0,0 +1,82 @@ +/* +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 ( + "reflect" + "sort" + "testing" + + "github.com/apache/incubator-devlake/helpers/e2ehelper" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/circleci/impl" + "github.com/apache/incubator-devlake/plugins/circleci/models" + "github.com/apache/incubator-devlake/plugins/circleci/tasks" + "github.com/stretchr/testify/assert" +) + +// TestCircleciUnfinishedJobsInputIterator is a regression test for +// https://github.com/apache/devlake/issues/8907. The "collect unfinished job +// details" collector builds its URL from "/v2/workflow/{{ .Input.Id }}/job" while +// scanning rows into a models.CircleciJob. Its input query must therefore expose the +// workflow id in the row's Id field; a bare "DISTINCT workflow_id" left Id empty and +// produced "/v2/workflow//job" (HTTP 500). This test runs the production query +// (tasks.UnfinishedJobsInputClauses) through the real iterator and asserts each +// yielded row's Id is the workflow id, that results are DISTINCT, and that the +// status/connection filters hold. +func TestCircleciUnfinishedJobsInputIterator(t *testing.T) { + var circleci impl.Circleci + dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci) + + const projectSlug = "github/test/repo" + dataflowTester.FlushTabler(&models.CircleciJob{}) + + seed := []models.CircleciJob{ + {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-1", ProjectSlug: projectSlug, Status: "on_hold"}, + {ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-2", ProjectSlug: projectSlug, Status: "running"}, // same workflow -> DISTINCT + {ConnectionId: 1, WorkflowId: "wf-queued", Id: "job-3", ProjectSlug: projectSlug, Status: "queued"}, + {ConnectionId: 1, WorkflowId: "wf-success", Id: "job-4", ProjectSlug: projectSlug, Status: "success"}, // terminal -> excluded + {ConnectionId: 2, WorkflowId: "wf-otherconn", Id: "job-5", ProjectSlug: projectSlug, Status: "on_hold"}, // other connection -> excluded + } + for i := range seed { + assert.Nil(t, dataflowTester.Dal.Create(&seed[i])) + } + + cursor, err := dataflowTester.Dal.Cursor(tasks.UnfinishedJobsInputClauses(1, projectSlug)...) + assert.Nil(t, err) + iter, err := api.NewDalCursorIterator(dataflowTester.Dal, cursor, reflect.TypeOf(models.CircleciJob{})) + assert.Nil(t, err) + defer iter.Close() + + var ids []string + for iter.HasNext() { + item, err := iter.Fetch() + assert.Nil(t, err) + job := item.(*models.CircleciJob) + ids = append(ids, job.Id) + } + sort.Strings(ids) + + // Distinct workflow ids for connection 1's non-terminal jobs, with Id populated + // (the URL template reads .Input.Id). wf-success (terminal) and wf-otherconn + // (connection 2) are excluded. + assert.Equal(t, []string{"wf-onhold", "wf-queued"}, ids) + for _, id := range ids { + assert.NotEmpty(t, id, "Input.Id must be the workflow id, not empty (#8907)") + } +} diff --git a/backend/plugins/circleci/tasks/job_collector.go b/backend/plugins/circleci/tasks/job_collector.go index fd1d78286bf..00fd234524f 100644 --- a/backend/plugins/circleci/tasks/job_collector.go +++ b/backend/plugins/circleci/tasks/job_collector.go @@ -41,6 +41,20 @@ var CollectJobsMeta = plugin.SubTaskMeta{ DomainTypes: []string{plugin.DOMAIN_TYPE_CICD}, } +// UnfinishedJobsInputClauses returns the DAL clauses that select the workflows whose +// jobs are still in a non-terminal status and therefore need their job details +// recollected by the CollectJobs "unfinished details" collector. +func UnfinishedJobsInputClauses(connectionId uint64, projectSlug string) []dal.Clause { + return []dal.Clause{ + dal.Select("DISTINCT workflow_id AS id"), // #8907: alias to id so {{ .Input.Id }} resolves when scanned into CircleciJob + dal.From(&models.CircleciJob{}), + dal.Where( + "connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')", + connectionId, projectSlug, + ), + } +} + func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error { rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, RAW_JOB_TABLE) logger := taskCtx.GetLogger() @@ -94,14 +108,8 @@ func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error { AfterResponse: ignoreDeletedBuilds, }, BuildInputIterator: func() (api.Iterator, errors.Error) { - clauses := []dal.Clause{ - dal.Select("DISTINCT workflow_id"), // Only need to recollect jobs for a workflow once - dal.From(&models.CircleciJob{}), - dal.Where("connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')", data.Options.ConnectionId, data.Options.ProjectSlug), - } - db := taskCtx.GetDal() - cursor, err := db.Cursor(clauses...) + cursor, err := db.Cursor(UnfinishedJobsInputClauses(data.Options.ConnectionId, data.Options.ProjectSlug)...) if err != nil { return nil, err }