diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7e1f4afd5a..a017de9c24 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -295,50 +295,6 @@ const docTemplate = `{ ] } }, - "/api/experimental/chats/insights/pull-requests": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Chats" - ], - "summary": "Get PR insights", - "operationId": "get-pr-insights", - "parameters": [ - { - "type": "string", - "description": "Start date (RFC3339)", - "name": "start_date", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "End date (RFC3339)", - "name": "end_date", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.PRInsightsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, "/api/experimental/chats/models": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -21328,191 +21284,6 @@ const docTemplate = `{ } } }, - "codersdk.PRInsightsModelBreakdown": { - "type": "object", - "properties": { - "cost_per_merged_pr_micros": { - "type": "integer" - }, - "display_name": { - "type": "string" - }, - "merge_rate": { - "type": "number" - }, - "merged_prs": { - "type": "integer" - }, - "model_config_id": { - "type": "string", - "format": "uuid" - }, - "provider": { - "type": "string" - }, - "total_additions": { - "type": "integer" - }, - "total_cost_micros": { - "type": "integer" - }, - "total_deletions": { - "type": "integer" - }, - "total_prs": { - "type": "integer" - } - } - }, - "codersdk.PRInsightsPullRequest": { - "type": "object", - "properties": { - "additions": { - "type": "integer" - }, - "approved": { - "type": "boolean" - }, - "author_avatar_url": { - "type": "string" - }, - "author_login": { - "type": "string" - }, - "base_branch": { - "type": "string" - }, - "changed_files": { - "type": "integer" - }, - "changes_requested": { - "type": "boolean" - }, - "chat_id": { - "type": "string", - "format": "uuid" - }, - "commits": { - "type": "integer" - }, - "cost_micros": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "deletions": { - "type": "integer" - }, - "draft": { - "type": "boolean" - }, - "model_display_name": { - "type": "string" - }, - "pr_number": { - "type": "integer" - }, - "pr_title": { - "type": "string" - }, - "pr_url": { - "type": "string" - }, - "reviewer_count": { - "type": "integer" - }, - "state": { - "type": "string" - } - } - }, - "codersdk.PRInsightsResponse": { - "type": "object", - "properties": { - "by_model": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsModelBreakdown" - } - }, - "recent_prs": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsPullRequest" - } - }, - "summary": { - "$ref": "#/definitions/codersdk.PRInsightsSummary" - }, - "time_series": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsTimeSeriesEntry" - } - } - } - }, - "codersdk.PRInsightsSummary": { - "type": "object", - "properties": { - "approval_rate": { - "type": "number" - }, - "cost_per_merged_pr_micros": { - "type": "integer" - }, - "merge_rate": { - "type": "number" - }, - "prev_cost_per_merged_pr_micros": { - "type": "integer" - }, - "prev_merge_rate": { - "type": "number" - }, - "prev_total_prs_created": { - "type": "integer" - }, - "prev_total_prs_merged": { - "type": "integer" - }, - "total_additions": { - "type": "integer" - }, - "total_cost_micros": { - "type": "integer" - }, - "total_deletions": { - "type": "integer" - }, - "total_prs_created": { - "type": "integer" - }, - "total_prs_merged": { - "type": "integer" - } - } - }, - "codersdk.PRInsightsTimeSeriesEntry": { - "type": "object", - "properties": { - "date": { - "type": "string", - "format": "date-time" - }, - "prs_closed": { - "type": "integer" - }, - "prs_created": { - "type": "integer" - }, - "prs_merged": { - "type": "integer" - } - } - }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f791b142d5..bcdee7377d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -256,46 +256,6 @@ ] } }, - "/api/experimental/chats/insights/pull-requests": { - "get": { - "produces": ["application/json"], - "tags": ["Chats"], - "summary": "Get PR insights", - "operationId": "get-pr-insights", - "parameters": [ - { - "type": "string", - "description": "Start date (RFC3339)", - "name": "start_date", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "End date (RFC3339)", - "name": "end_date", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.PRInsightsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, "/api/experimental/chats/models": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -19460,191 +19420,6 @@ } } }, - "codersdk.PRInsightsModelBreakdown": { - "type": "object", - "properties": { - "cost_per_merged_pr_micros": { - "type": "integer" - }, - "display_name": { - "type": "string" - }, - "merge_rate": { - "type": "number" - }, - "merged_prs": { - "type": "integer" - }, - "model_config_id": { - "type": "string", - "format": "uuid" - }, - "provider": { - "type": "string" - }, - "total_additions": { - "type": "integer" - }, - "total_cost_micros": { - "type": "integer" - }, - "total_deletions": { - "type": "integer" - }, - "total_prs": { - "type": "integer" - } - } - }, - "codersdk.PRInsightsPullRequest": { - "type": "object", - "properties": { - "additions": { - "type": "integer" - }, - "approved": { - "type": "boolean" - }, - "author_avatar_url": { - "type": "string" - }, - "author_login": { - "type": "string" - }, - "base_branch": { - "type": "string" - }, - "changed_files": { - "type": "integer" - }, - "changes_requested": { - "type": "boolean" - }, - "chat_id": { - "type": "string", - "format": "uuid" - }, - "commits": { - "type": "integer" - }, - "cost_micros": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "deletions": { - "type": "integer" - }, - "draft": { - "type": "boolean" - }, - "model_display_name": { - "type": "string" - }, - "pr_number": { - "type": "integer" - }, - "pr_title": { - "type": "string" - }, - "pr_url": { - "type": "string" - }, - "reviewer_count": { - "type": "integer" - }, - "state": { - "type": "string" - } - } - }, - "codersdk.PRInsightsResponse": { - "type": "object", - "properties": { - "by_model": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsModelBreakdown" - } - }, - "recent_prs": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsPullRequest" - } - }, - "summary": { - "$ref": "#/definitions/codersdk.PRInsightsSummary" - }, - "time_series": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.PRInsightsTimeSeriesEntry" - } - } - } - }, - "codersdk.PRInsightsSummary": { - "type": "object", - "properties": { - "approval_rate": { - "type": "number" - }, - "cost_per_merged_pr_micros": { - "type": "integer" - }, - "merge_rate": { - "type": "number" - }, - "prev_cost_per_merged_pr_micros": { - "type": "integer" - }, - "prev_merge_rate": { - "type": "number" - }, - "prev_total_prs_created": { - "type": "integer" - }, - "prev_total_prs_merged": { - "type": "integer" - }, - "total_additions": { - "type": "integer" - }, - "total_cost_micros": { - "type": "integer" - }, - "total_deletions": { - "type": "integer" - }, - "total_prs_created": { - "type": "integer" - }, - "total_prs_merged": { - "type": "integer" - } - } - }, - "codersdk.PRInsightsTimeSeriesEntry": { - "type": "object", - "properties": { - "date": { - "type": "string", - "format": "date-time" - }, - "prs_closed": { - "type": "integer" - }, - "prs_created": { - "type": "integer" - }, - "prs_merged": { - "type": "integer" - } - } - }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 48ae8e9267..601669d321 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1253,9 +1253,6 @@ func New(options *Options) *API { r.Get("/summary", api.chatCostSummary) }) }) - r.Route("/insights", func(r chi.Router) { - r.Get("/pull-requests", api.prInsights) - }) r.Route("/files", func(r chi.Router) { r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute)) r.Post("/", api.postChatFile) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index fabb470cb2..47f38d4d0a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4170,34 +4170,6 @@ func (q *querier) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg da return q.db.GetOrganizationsWithPrebuildStatus(ctx, arg) } -func (q *querier) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetPRInsightsPerModel(ctx, arg) -} - -func (q *querier) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetPRInsightsPullRequests(ctx, arg) -} - -func (q *querier) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.GetPRInsightsSummaryRow{}, err - } - return q.db.GetPRInsightsSummary(ctx, arg) -} - -func (q *querier) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetPRInsightsTimeSeries(ctx, arg) -} - func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) { version, err := q.db.GetTemplateVersionByJobID(ctx, jobID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 181ce84c8f..9565455ffb 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2911,26 +2911,6 @@ func (s *MethodTestSuite) TestTemplate() { dbm.EXPECT().GetTemplateInsightsByTemplate(gomock.Any(), arg).Return([]database.GetTemplateInsightsByTemplateRow{}, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) })) - s.Run("GetPRInsightsSummary", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.GetPRInsightsSummaryParams{} - dbm.EXPECT().GetPRInsightsSummary(gomock.Any(), arg).Return(database.GetPRInsightsSummaryRow{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetPRInsightsTimeSeries", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.GetPRInsightsTimeSeriesParams{} - dbm.EXPECT().GetPRInsightsTimeSeries(gomock.Any(), arg).Return([]database.GetPRInsightsTimeSeriesRow{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetPRInsightsPerModel", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.GetPRInsightsPerModelParams{} - dbm.EXPECT().GetPRInsightsPerModel(gomock.Any(), arg).Return([]database.GetPRInsightsPerModelRow{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetPRInsightsPullRequests", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.GetPRInsightsPullRequestsParams{} - dbm.EXPECT().GetPRInsightsPullRequests(gomock.Any(), arg).Return([]database.GetPRInsightsPullRequestsRow{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) s.Run("GetTelemetryTaskEvents", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.GetTelemetryTaskEventsParams{} dbm.EXPECT().GetTelemetryTaskEvents(gomock.Any(), arg).Return([]database.GetTelemetryTaskEventsRow{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d760236922..ee9e80d781 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2546,38 +2546,6 @@ func (m queryMetricsStore) GetOrganizationsWithPrebuildStatus(ctx context.Contex return r0, r1 } -func (m queryMetricsStore) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) { - start := time.Now() - r0, r1 := m.s.GetPRInsightsPerModel(ctx, arg) - m.queryLatencies.WithLabelValues("GetPRInsightsPerModel").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsPerModel").Inc() - return r0, r1 -} - -func (m queryMetricsStore) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) { - start := time.Now() - r0, r1 := m.s.GetPRInsightsPullRequests(ctx, arg) - m.queryLatencies.WithLabelValues("GetPRInsightsPullRequests").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsPullRequests").Inc() - return r0, r1 -} - -func (m queryMetricsStore) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) { - start := time.Now() - r0, r1 := m.s.GetPRInsightsSummary(ctx, arg) - m.queryLatencies.WithLabelValues("GetPRInsightsSummary").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsSummary").Inc() - return r0, r1 -} - -func (m queryMetricsStore) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) { - start := time.Now() - r0, r1 := m.s.GetPRInsightsTimeSeries(ctx, arg) - m.queryLatencies.WithLabelValues("GetPRInsightsTimeSeries").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsTimeSeries").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) { start := time.Now() r0, r1 := m.s.GetParameterSchemasByJobID(ctx, jobID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 81389ab649..7c7c3aec7e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4722,66 +4722,6 @@ func (mr *MockStoreMockRecorder) GetOrganizationsWithPrebuildStatus(ctx, arg any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsWithPrebuildStatus", reflect.TypeOf((*MockStore)(nil).GetOrganizationsWithPrebuildStatus), ctx, arg) } -// GetPRInsightsPerModel mocks base method. -func (m *MockStore) GetPRInsightsPerModel(ctx context.Context, arg database.GetPRInsightsPerModelParams) ([]database.GetPRInsightsPerModelRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPRInsightsPerModel", ctx, arg) - ret0, _ := ret[0].([]database.GetPRInsightsPerModelRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPRInsightsPerModel indicates an expected call of GetPRInsightsPerModel. -func (mr *MockStoreMockRecorder) GetPRInsightsPerModel(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsPerModel", reflect.TypeOf((*MockStore)(nil).GetPRInsightsPerModel), ctx, arg) -} - -// GetPRInsightsPullRequests mocks base method. -func (m *MockStore) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPRInsightsPullRequests", ctx, arg) - ret0, _ := ret[0].([]database.GetPRInsightsPullRequestsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPRInsightsPullRequests indicates an expected call of GetPRInsightsPullRequests. -func (mr *MockStoreMockRecorder) GetPRInsightsPullRequests(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsPullRequests", reflect.TypeOf((*MockStore)(nil).GetPRInsightsPullRequests), ctx, arg) -} - -// GetPRInsightsSummary mocks base method. -func (m *MockStore) GetPRInsightsSummary(ctx context.Context, arg database.GetPRInsightsSummaryParams) (database.GetPRInsightsSummaryRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPRInsightsSummary", ctx, arg) - ret0, _ := ret[0].(database.GetPRInsightsSummaryRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPRInsightsSummary indicates an expected call of GetPRInsightsSummary. -func (mr *MockStoreMockRecorder) GetPRInsightsSummary(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsSummary", reflect.TypeOf((*MockStore)(nil).GetPRInsightsSummary), ctx, arg) -} - -// GetPRInsightsTimeSeries mocks base method. -func (m *MockStore) GetPRInsightsTimeSeries(ctx context.Context, arg database.GetPRInsightsTimeSeriesParams) ([]database.GetPRInsightsTimeSeriesRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPRInsightsTimeSeries", ctx, arg) - ret0, _ := ret[0].([]database.GetPRInsightsTimeSeriesRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPRInsightsTimeSeries indicates an expected call of GetPRInsightsTimeSeries. -func (mr *MockStoreMockRecorder) GetPRInsightsTimeSeries(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsTimeSeries", reflect.TypeOf((*MockStore)(nil).GetPRInsightsTimeSeries), ctx, arg) -} - // GetParameterSchemasByJobID mocks base method. func (m *MockStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 55db066a02..4645255530 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -641,42 +641,6 @@ type sqlcQuerier interface { // GetOrganizationsWithPrebuildStatus returns organizations with prebuilds configured and their // membership status for the prebuilds system user (org membership, group existence, group membership). GetOrganizationsWithPrebuildStatus(ctx context.Context, arg GetOrganizationsWithPrebuildStatusParams) ([]GetOrganizationsWithPrebuildStatusRow, error) - // Returns PR metrics grouped by the model used for each chat. - // Uses two CTEs: pr_costs sums cost for the PR-linked chat and its - // direct children (that lack their own PR), and deduped picks one row - // per PR for state/additions/deletions/model (model comes from the - // most recent chat). - GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error) - // Returns all individual PR rows with cost for the selected time range. - // Uses two CTEs: pr_costs sums cost for the PR-linked chat and its - // direct children (that lack their own PR), and deduped picks one row - // per PR for metadata. A safety-cap LIMIT guards against unexpectedly - // large result sets from direct API callers. - GetPRInsightsPullRequests(ctx context.Context, arg GetPRInsightsPullRequestsParams) ([]GetPRInsightsPullRequestsRow, error) - // PR Insights queries for the /agents analytics dashboard. - // These aggregate data from chat_diff_statuses (PR metadata) joined - // with chats and chat_messages (cost) to power the PR Insights view. - // - // Cost is computed per PR by summing the PR-linked chat's own cost plus - // the costs of any direct children (subagents) it spawned that do NOT - // have their own PR association. If a child chat has its own - // chat_diff_statuses entry (with a non-NULL pull_request_state), its - // cost is attributed to that child's PR instead — preventing - // double-counting when sibling chats create different PRs. - // Subagent trees are at most 2 levels deep (enforced by the - // application layer). PR metadata (state, additions, deletions) - // comes from the most recent chat via DISTINCT ON so that each PR - // is counted exactly once. - // Returns aggregate PR metrics for the given date range. - // The handler calls this twice (current + previous period) for trends. - // Uses two CTEs: pr_costs sums cost for the PR-linked chat and its - // direct children (that lack their own PR), and deduped picks one row - // per PR for state/additions/deletions. - GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error) - // Returns daily PR counts grouped by state for the chart. - // Uses a CTE to deduplicate by PR URL so that multiple chats referencing - // the same pull request are only counted once (keeping the most recent chat). - GetPRInsightsTimeSeries(ctx context.Context, arg GetPRInsightsTimeSeriesParams) ([]GetPRInsightsTimeSeriesRow, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error) GetPrebuildsSettings(ctx context.Context) (string, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 7dbdccf93f..5d8d4a600e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -11761,569 +11761,6 @@ func TestUpsertAISeats(t *testing.T) { require.False(t, alreadyExists) } -func TestGetPRInsights(t *testing.T) { - t.Parallel() - if testing.Short() { - t.SkipNow() - } - - // setupChatInfra creates a fresh database with a user, chat provider, - // and model config. Returns the store, user ID, model config ID, - // and org ID. - setupChatInfra := func(t *testing.T) (database.Store, uuid.UUID, uuid.UUID, uuid.UUID) { - t.Helper() - store, _ := dbtestutil.NewDB(t) - ctx := context.Background() - org := dbgen.Organization(t, store, database.Organization{}) - user := dbgen.User(t, store, database.User{}) - dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) - - dbgen.ChatProvider(t, store, database.ChatProvider{ - Provider: "anthropic", - DisplayName: "Anthropic", - APIKey: "test-key", - Enabled: true, - CentralApiKeyEnabled: true, - }) - - mc, err := insertChatModelConfigForTest(ctx, t, store, database.InsertChatModelConfigParams{ - Provider: "anthropic", - Model: "claude-4", - DisplayName: "Claude 4", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - Enabled: true, - IsDefault: true, - ContextLimit: 128000, - CompressionThreshold: 80, - Options: json.RawMessage(`{}`), - }) - require.NoError(t, err) - - return store, user.ID, mc.ID, org.ID - } - - type chatParams struct { - Store database.Store - UserID uuid.UUID - ModelConfigID uuid.UUID - OrgID uuid.UUID - } - - createChat := func(t *testing.T, p chatParams, title string) database.Chat { - t.Helper() - chat, err := p.Store.InsertChat(context.Background(), database.InsertChatParams{ - OrganizationID: p.OrgID, - Status: database.ChatStatusWaiting, - ClientType: database.ChatClientTypeUi, - OwnerID: p.UserID, - LastModelConfigID: p.ModelConfigID, - Title: title, - }) - require.NoError(t, err) - return chat - } - - // insertCostMessage inserts a single assistant message with the - // given total_cost_micros value. - insertCostMessage := func(t *testing.T, store database.Store, chatID, userID, mcID uuid.UUID, costMicros int64) { - t.Helper() - _, err := store.InsertChatMessages(context.Background(), database.InsertChatMessagesParams{ - ChatID: chatID, - CreatedBy: []uuid.UUID{userID}, - ModelConfigID: []uuid.UUID{mcID}, - Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, - Content: []string{`[{"type":"text","text":"hello"}]`}, - ContentVersion: []int16{1}, - Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, - InputTokens: []int64{0}, - OutputTokens: []int64{0}, - TotalTokens: []int64{0}, - ReasoningTokens: []int64{0}, - CacheCreationTokens: []int64{0}, - CacheReadTokens: []int64{0}, - ContextLimit: []int64{0}, - Compressed: []bool{false}, - TotalCostMicros: []int64{costMicros}, - RuntimeMs: []int64{0}, - }) - require.NoError(t, err) - } - - // linkPR associates a chat with a pull request via - // UpsertChatDiffStatus. - linkPR := func(t *testing.T, store database.Store, chatID uuid.UUID, prURL, state, title string, additions, deletions, changed int32) { - t.Helper() - now := time.Now() - _, err := store.UpsertChatDiffStatus(context.Background(), database.UpsertChatDiffStatusParams{ - ChatID: chatID, - Url: sql.NullString{String: prURL, Valid: true}, - PullRequestState: sql.NullString{String: state, Valid: true}, - PullRequestTitle: title, - Additions: additions, - Deletions: deletions, - ChangedFiles: changed, - RefreshedAt: now, - StaleAt: now.Add(time.Hour), - }) - require.NoError(t, err) - } - - startDate := time.Now().Add(-24 * time.Hour) - endDate := time.Now().Add(time.Hour) - noOwner := uuid.NullUUID{} - - t.Run("MultipleChatsSamePR_CostSummed", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - chatA := createChat(t, p, "chat-A") - insertCostMessage(t, store, chatA.ID, userID, mcID, 5_000_000) // $5 - - chatB := createChat(t, p, "chat-B") - insertCostMessage(t, store, chatB.ID, userID, mcID, 3_000_000) // $3 - - prURL := "https://github.com/org/repo/pull/123" - linkPR(t, store, chatA.ID, prURL, "merged", "fix: something", 100, 20, 5) - linkPR(t, store, chatB.ID, prURL, "merged", "fix: something", 100, 20, 5) - - // Both chats reference the same PR. The pr_costs CTE sums - // cost across all chats for the same PR URL, so the total - // should be $5 + $3 = $8. The PR itself is counted once. - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), summary.TotalPrsCreated) - assert.Equal(t, int64(8_000_000), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - assert.Equal(t, int64(8_000_000), recent[0].CostMicros) - }) - - t.Run("DifferentPRs_NoDuplication", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - chatA := createChat(t, p, "chat-A") - insertCostMessage(t, store, chatA.ID, userID, mcID, 5_000_000) - linkPR(t, store, chatA.ID, "https://github.com/org/repo/pull/1", "merged", "feat: A", 50, 10, 2) - - chatB := createChat(t, p, "chat-B") - insertCostMessage(t, store, chatB.ID, userID, mcID, 3_000_000) - linkPR(t, store, chatB.ID, "https://github.com/org/repo/pull/2", "open", "feat: B", 80, 30, 4) - - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(2), summary.TotalPrsCreated) - assert.Equal(t, int64(8_000_000), summary.TotalCostMicros) // $5 + $3 - assert.Equal(t, int64(1), summary.TotalPrsMerged) - - // RecentPRs ordered by created_at DESC: chatB is newer. - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 2) - // Costs must not be mixed across different PRs. - assert.Equal(t, int64(3_000_000), recent[0].CostMicros) // PR 2 (newer) - assert.Equal(t, int64(5_000_000), recent[1].CostMicros) // PR 1 (older) - }) - - // createChildChat creates a chat with ParentChatID and RootChatID - // set, simulating a subagent/child chat in a tree. - createChildChat := func(t *testing.T, p chatParams, parentID, rootID uuid.UUID, title string) database.Chat { - t.Helper() - chat, err := p.Store.InsertChat(context.Background(), database.InsertChatParams{ - OrganizationID: p.OrgID, - Status: database.ChatStatusWaiting, - ClientType: database.ChatClientTypeUi, - OwnerID: p.UserID, - LastModelConfigID: p.ModelConfigID, - Title: title, - ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true}, - RootChatID: uuid.NullUUID{UUID: rootID, Valid: true}, - }) - require.NoError(t, err) - return chat - } - - t.Run("DuplicatePRUrl_CountedOnce", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - prURL := "https://github.com/org/repo/pull/99" - for i := range 3 { - chat := createChat(t, p, fmt.Sprintf("chat-%d", i)) - insertCostMessage(t, store, chat.ID, userID, mcID, 1_000_000) - linkPR(t, store, chat.ID, prURL, "merged", "fix: same PR", 40, 10, 3) - } - - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), summary.TotalPrsCreated) - assert.Equal(t, int64(1), summary.TotalPrsMerged) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - }) - - t.Run("ChildChatCostsIncluded", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Parent chat with a $5 cost. - parent := createChat(t, p, "parent-chat") - insertCostMessage(t, store, parent.ID, userID, mcID, 5_000_000) - - // Two child chats (subagents) with $2 each. Only the parent - // has a chat_diff_statuses entry, but the children's costs - // should be included via the tree join. - child1 := createChildChat(t, p, parent.ID, parent.ID, "child-1") - insertCostMessage(t, store, child1.ID, userID, mcID, 2_000_000) - - child2 := createChildChat(t, p, parent.ID, parent.ID, "child-2") - insertCostMessage(t, store, child2.ID, userID, mcID, 2_000_000) - - prURL := "https://github.com/org/repo/pull/42" - linkPR(t, store, parent.ID, prURL, "merged", "feat: tree cost", 60, 15, 3) - - // Summary should reflect $5 + $2 + $2 = $9 total. - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), summary.TotalPrsCreated) - assert.Equal(t, int64(1), summary.TotalPrsMerged) - assert.Equal(t, int64(9_000_000), summary.TotalCostMicros) - - // RecentPRs should return 1 row with the full tree cost. - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - assert.Equal(t, int64(9_000_000), recent[0].CostMicros) - }) - - t.Run("SiblingPRs_NoCrossContamination", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Parent chat with $10 orchestration cost. - parent := createChat(t, p, "parent") - insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000) - - // Child C1 ($5) creates PR1. - c1 := createChildChat(t, p, parent.ID, parent.ID, "child-1") - insertCostMessage(t, store, c1.ID, userID, mcID, 5_000_000) - linkPR(t, store, c1.ID, "https://github.com/org/repo/pull/10", "merged", "feat: PR1", 50, 10, 2) - - // Child C2 ($3) creates PR2. - c2 := createChildChat(t, p, parent.ID, parent.ID, "child-2") - insertCostMessage(t, store, c2.ID, userID, mcID, 3_000_000) - linkPR(t, store, c2.ID, "https://github.com/org/repo/pull/11", "open", "feat: PR2", 30, 5, 1) - - // With direct-branch attribution: - // PR1 cost = C1's own cost = $5 (parent NOT included — only children of C1) - // PR2 cost = C2's own cost = $3 - // Total = $8 (no double-counting of parent or siblings) - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(2), summary.TotalPrsCreated) - assert.Equal(t, int64(8_000_000), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 2) - // PR2 (newer) = $3, PR1 (older) = $5. - assert.Equal(t, int64(3_000_000), recent[0].CostMicros) - assert.Equal(t, int64(5_000_000), recent[1].CostMicros) - }) - - t.Run("ParentAndChildDifferentPRs_NoCrossContamination", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Parent P ($10) creates PR1. - parent := createChat(t, p, "parent") - insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000) - linkPR(t, store, parent.ID, "https://github.com/org/repo/pull/20", "merged", "feat: parent PR", 80, 20, 4) - - // Child C1 ($5) has its own PR2. Because C1 has its own - // chat_diff_statuses entry, its cost should NOT be included - // under PR1 — it belongs to PR2 only. - c1 := createChildChat(t, p, parent.ID, parent.ID, "child-1") - insertCostMessage(t, store, c1.ID, userID, mcID, 5_000_000) - linkPR(t, store, c1.ID, "https://github.com/org/repo/pull/21", "open", "feat: child PR", 30, 5, 1) - - // Child C2 ($2) has NO cds entry — pure subagent. - // Its cost should be included under PR1 (the parent's PR). - c2 := createChildChat(t, p, parent.ID, parent.ID, "child-2") - insertCostMessage(t, store, c2.ID, userID, mcID, 2_000_000) - - // PR1 cost = parent ($10) + C2 ($2) = $12 (C1 excluded) - // PR2 cost = C1 ($5) - // Total = $17 (actual spend: $10 + $5 + $2 = $17) - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(2), summary.TotalPrsCreated) - assert.Equal(t, int64(17_000_000), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 2) - // PR2/C1 (newer) = $5, PR1/parent (older) = $12. - assert.Equal(t, int64(5_000_000), recent[0].CostMicros) - assert.Equal(t, int64(12_000_000), recent[1].CostMicros) - }) - - t.Run("EmptyURLNotCollapsed", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Two chats with empty-string URLs should be treated as - // separate PRs (NULLIF converts '' to NULL, falling back - // to c.id::text). - chatX := createChat(t, p, "chat-X") - insertCostMessage(t, store, chatX.ID, userID, mcID, 4_000_000) - linkPR(t, store, chatX.ID, "", "open", "draft: X", 10, 2, 1) - - chatY := createChat(t, p, "chat-Y") - insertCostMessage(t, store, chatY.ID, userID, mcID, 6_000_000) - linkPR(t, store, chatY.ID, "", "merged", "draft: Y", 20, 5, 2) - - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(2), summary.TotalPrsCreated) - assert.Equal(t, int64(10_000_000), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 2) - }) - - t.Run("ParentAndChildSameURL_DedupedWithCombinedCost", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Parent P ($10) links to a PR. - parent := createChat(t, p, "parent") - insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000) - - // Child C ($5) also links to the same PR URL. - child := createChildChat(t, p, parent.ID, parent.ID, "child") - insertCostMessage(t, store, child.ID, userID, mcID, 5_000_000) - - prURL := "https://github.com/org/repo/pull/50" - linkPR(t, store, parent.ID, prURL, "merged", "feat: shared PR", 70, 15, 3) - linkPR(t, store, child.ID, prURL, "merged", "feat: shared PR", 70, 15, 3) - - // Both parent and child have cds entries for the same URL. - // The PR should be counted once with combined cost $10 + $5 = $15. - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), summary.TotalPrsCreated) - assert.Equal(t, int64(15_000_000), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - assert.Equal(t, int64(15_000_000), recent[0].CostMicros) - }) - - t.Run("ZeroCostChat_StillCounted", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // A chat linked to a PR but with NO chat_messages at all. - // The PR should still appear with zero cost. - chat := createChat(t, p, "zero-cost-chat") - linkPR(t, store, chat.ID, "https://github.com/org/repo/pull/60", "open", "feat: no messages", 25, 5, 2) - - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(1), summary.TotalPrsCreated) - assert.Equal(t, int64(0), summary.TotalCostMicros) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - assert.Equal(t, int64(0), recent[0].CostMicros) - }) - - t.Run("BlankDisplayNameFallsBackToModel", func(t *testing.T) { - t.Parallel() - store, userID, _, orgID := setupChatInfra(t) - - const modelName = "claude-4.1" - emptyDisplayModel, err := insertChatModelConfigForTest(context.Background(), t, store, database.InsertChatModelConfigParams{ - Provider: "anthropic", - Model: modelName, - DisplayName: "", - CreatedBy: uuid.NullUUID{UUID: userID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: userID, Valid: true}, - Enabled: true, - IsDefault: false, - ContextLimit: 128000, - CompressionThreshold: 80, - Options: json.RawMessage(`{}`), - }) - require.NoError(t, err) - - p := chatParams{Store: store, UserID: userID, ModelConfigID: emptyDisplayModel.ID, OrgID: orgID} - chat := createChat(t, p, "chat-empty-display-name") - insertCostMessage(t, store, chat.ID, userID, emptyDisplayModel.ID, 1_000_000) - linkPR(t, store, chat.ID, "https://github.com/org/repo/pull/72", "merged", "fix: blank display name", 10, 2, 1) - - byModel, err := store.GetPRInsightsPerModel(context.Background(), database.GetPRInsightsPerModelParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, byModel, 1) - assert.Equal(t, modelName, byModel[0].DisplayName) - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - require.Len(t, recent, 1) - assert.Equal(t, modelName, recent[0].ModelDisplayName) - }) - - t.Run("MergedCostMicros_OnlyCountsMerged", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Merged PR with $5 cost. - chatMerged := createChat(t, p, "chat-merged") - insertCostMessage(t, store, chatMerged.ID, userID, mcID, 5_000_000) - linkPR(t, store, chatMerged.ID, "https://github.com/org/repo/pull/70", "merged", "fix: merged", 40, 10, 2) - - // Open PR with $3 cost. - chatOpen := createChat(t, p, "chat-open") - insertCostMessage(t, store, chatOpen.ID, userID, mcID, 3_000_000) - linkPR(t, store, chatOpen.ID, "https://github.com/org/repo/pull/71", "open", "feat: open", 20, 5, 1) - - // TotalCostMicros includes both ($5 + $3 = $8), but - // MergedCostMicros only includes the merged PR ($5). - summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Equal(t, int64(8_000_000), summary.TotalCostMicros) - assert.Equal(t, int64(5_000_000), summary.MergedCostMicros) - }) - - t.Run("AllPRsReturnedWithSafetyCap", func(t *testing.T) { - t.Parallel() - store, userID, mcID, orgID := setupChatInfra(t) - p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID} - - // Create 25 distinct PRs — more than the old LIMIT 20 — and - // verify all are returned. - const prCount = 25 - for i := range prCount { - chat := createChat(t, p, fmt.Sprintf("chat-%d", i)) - insertCostMessage(t, store, chat.ID, userID, mcID, 1_000_000) - linkPR(t, store, chat.ID, - fmt.Sprintf("https://github.com/org/repo/pull/%d", 100+i), - "merged", fmt.Sprintf("fix: pr-%d", i), 10, 2, 1) - } - - recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: noOwner, - }) - require.NoError(t, err) - assert.Len(t, recent, prCount, "all PRs within the date range should be returned") - }) -} - func TestChatPinOrderQueries(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e6a49465dd..cc5301a857 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4950,473 +4950,6 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam return i, err } -const getPRInsightsPerModel = `-- name: GetPRInsightsPerModel :many -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - cds.pull_request_state, - cds.additions, - cds.deletions, - cmc.id AS model_config_id, - cmc.display_name, - cmc.model, - cmc.provider - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - d.model_config_id, - COALESCE(NULLIF(d.display_name, ''), NULLIF(d.model, ''), 'Unknown')::text AS display_name, - COALESCE(d.provider, 'unknown')::text AS provider, - COUNT(*)::bigint AS total_prs, - COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS merged_prs, - COALESCE(SUM(d.additions), 0)::bigint AS total_additions, - COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions, - COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros, - COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros -FROM deduped d -JOIN pr_costs pc ON pc.pr_key = d.pr_key -GROUP BY d.model_config_id, d.display_name, d.model, d.provider -ORDER BY total_prs DESC -` - -type GetPRInsightsPerModelParams struct { - StartDate time.Time `db:"start_date" json:"start_date"` - EndDate time.Time `db:"end_date" json:"end_date"` - OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` -} - -type GetPRInsightsPerModelRow struct { - ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` - DisplayName string `db:"display_name" json:"display_name"` - Provider string `db:"provider" json:"provider"` - TotalPrs int64 `db:"total_prs" json:"total_prs"` - MergedPrs int64 `db:"merged_prs" json:"merged_prs"` - TotalAdditions int64 `db:"total_additions" json:"total_additions"` - TotalDeletions int64 `db:"total_deletions" json:"total_deletions"` - TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` - MergedCostMicros int64 `db:"merged_cost_micros" json:"merged_cost_micros"` -} - -// Returns PR metrics grouped by the model used for each chat. -// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its -// direct children (that lack their own PR), and deduped picks one row -// per PR for state/additions/deletions/model (model comes from the -// most recent chat). -func (q *sqlQuerier) GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error) { - rows, err := q.db.QueryContext(ctx, getPRInsightsPerModel, arg.StartDate, arg.EndDate, arg.OwnerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetPRInsightsPerModelRow - for rows.Next() { - var i GetPRInsightsPerModelRow - if err := rows.Scan( - &i.ModelConfigID, - &i.DisplayName, - &i.Provider, - &i.TotalPrs, - &i.MergedPrs, - &i.TotalAdditions, - &i.TotalDeletions, - &i.TotalCostMicros, - &i.MergedCostMicros, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getPRInsightsPullRequests = `-- name: GetPRInsightsPullRequests :many -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - c.id AS chat_id, - cds.pull_request_title AS pr_title, - cds.url AS pr_url, - cds.pr_number, - cds.pull_request_state AS state, - cds.pull_request_draft AS draft, - cds.additions, - cds.deletions, - cds.changed_files, - cds.commits, - cds.approved, - cds.changes_requested, - cds.reviewer_count, - cds.author_login, - cds.author_avatar_url, - COALESCE(cds.base_branch, '')::text AS base_branch, - COALESCE(NULLIF(cmc.display_name, ''), NULLIF(cmc.model, ''), 'Unknown')::text AS model_display_name, - c.created_at - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT chat_id, pr_title, pr_url, pr_number, state, draft, additions, deletions, changed_files, commits, approved, changes_requested, reviewer_count, author_login, author_avatar_url, base_branch, model_display_name, cost_micros, created_at FROM ( - SELECT - d.chat_id, - d.pr_title, - d.pr_url, - d.pr_number, - d.state, - d.draft, - d.additions, - d.deletions, - d.changed_files, - d.commits, - d.approved, - d.changes_requested, - d.reviewer_count, - d.author_login, - d.author_avatar_url, - d.base_branch, - d.model_display_name, - COALESCE(pc.cost_micros, 0)::bigint AS cost_micros, - d.created_at - FROM deduped d - JOIN pr_costs pc ON pc.pr_key = d.pr_key -) sub -ORDER BY sub.created_at DESC -LIMIT 500 -` - -type GetPRInsightsPullRequestsParams struct { - StartDate time.Time `db:"start_date" json:"start_date"` - EndDate time.Time `db:"end_date" json:"end_date"` - OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` -} - -type GetPRInsightsPullRequestsRow struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - PrTitle string `db:"pr_title" json:"pr_title"` - PrUrl sql.NullString `db:"pr_url" json:"pr_url"` - PrNumber sql.NullInt32 `db:"pr_number" json:"pr_number"` - State sql.NullString `db:"state" json:"state"` - Draft bool `db:"draft" json:"draft"` - Additions int32 `db:"additions" json:"additions"` - Deletions int32 `db:"deletions" json:"deletions"` - ChangedFiles int32 `db:"changed_files" json:"changed_files"` - Commits sql.NullInt32 `db:"commits" json:"commits"` - Approved sql.NullBool `db:"approved" json:"approved"` - ChangesRequested bool `db:"changes_requested" json:"changes_requested"` - ReviewerCount sql.NullInt32 `db:"reviewer_count" json:"reviewer_count"` - AuthorLogin sql.NullString `db:"author_login" json:"author_login"` - AuthorAvatarUrl sql.NullString `db:"author_avatar_url" json:"author_avatar_url"` - BaseBranch string `db:"base_branch" json:"base_branch"` - ModelDisplayName string `db:"model_display_name" json:"model_display_name"` - CostMicros int64 `db:"cost_micros" json:"cost_micros"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} - -// Returns all individual PR rows with cost for the selected time range. -// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its -// direct children (that lack their own PR), and deduped picks one row -// per PR for metadata. A safety-cap LIMIT guards against unexpectedly -// large result sets from direct API callers. -func (q *sqlQuerier) GetPRInsightsPullRequests(ctx context.Context, arg GetPRInsightsPullRequestsParams) ([]GetPRInsightsPullRequestsRow, error) { - rows, err := q.db.QueryContext(ctx, getPRInsightsPullRequests, arg.StartDate, arg.EndDate, arg.OwnerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetPRInsightsPullRequestsRow - for rows.Next() { - var i GetPRInsightsPullRequestsRow - if err := rows.Scan( - &i.ChatID, - &i.PrTitle, - &i.PrUrl, - &i.PrNumber, - &i.State, - &i.Draft, - &i.Additions, - &i.Deletions, - &i.ChangedFiles, - &i.Commits, - &i.Approved, - &i.ChangesRequested, - &i.ReviewerCount, - &i.AuthorLogin, - &i.AuthorAvatarUrl, - &i.BaseBranch, - &i.ModelDisplayName, - &i.CostMicros, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getPRInsightsSummary = `-- name: GetPRInsightsSummary :one - -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - -- For each PR, include the chat that references it plus any - -- direct children (subagents) that do not have their own PR. - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - cds.pull_request_state, - cds.additions, - cds.deletions - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - COUNT(*)::bigint AS total_prs_created, - COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS total_prs_merged, - COUNT(*) FILTER (WHERE d.pull_request_state = 'closed')::bigint AS total_prs_closed, - COALESCE(SUM(d.additions), 0)::bigint AS total_additions, - COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions, - COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros, - COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros -FROM deduped d -JOIN pr_costs pc ON pc.pr_key = d.pr_key -` - -type GetPRInsightsSummaryParams struct { - StartDate time.Time `db:"start_date" json:"start_date"` - EndDate time.Time `db:"end_date" json:"end_date"` - OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` -} - -type GetPRInsightsSummaryRow struct { - TotalPrsCreated int64 `db:"total_prs_created" json:"total_prs_created"` - TotalPrsMerged int64 `db:"total_prs_merged" json:"total_prs_merged"` - TotalPrsClosed int64 `db:"total_prs_closed" json:"total_prs_closed"` - TotalAdditions int64 `db:"total_additions" json:"total_additions"` - TotalDeletions int64 `db:"total_deletions" json:"total_deletions"` - TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` - MergedCostMicros int64 `db:"merged_cost_micros" json:"merged_cost_micros"` -} - -// PR Insights queries for the /agents analytics dashboard. -// These aggregate data from chat_diff_statuses (PR metadata) joined -// with chats and chat_messages (cost) to power the PR Insights view. -// -// Cost is computed per PR by summing the PR-linked chat's own cost plus -// the costs of any direct children (subagents) it spawned that do NOT -// have their own PR association. If a child chat has its own -// chat_diff_statuses entry (with a non-NULL pull_request_state), its -// cost is attributed to that child's PR instead — preventing -// double-counting when sibling chats create different PRs. -// Subagent trees are at most 2 levels deep (enforced by the -// application layer). PR metadata (state, additions, deletions) -// comes from the most recent chat via DISTINCT ON so that each PR -// is counted exactly once. -// Returns aggregate PR metrics for the given date range. -// The handler calls this twice (current + previous period) for trends. -// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its -// direct children (that lack their own PR), and deduped picks one row -// per PR for state/additions/deletions. -func (q *sqlQuerier) GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error) { - row := q.db.QueryRowContext(ctx, getPRInsightsSummary, arg.StartDate, arg.EndDate, arg.OwnerID) - var i GetPRInsightsSummaryRow - err := row.Scan( - &i.TotalPrsCreated, - &i.TotalPrsMerged, - &i.TotalPrsClosed, - &i.TotalAdditions, - &i.TotalDeletions, - &i.TotalCostMicros, - &i.MergedCostMicros, - ) - return i, err -} - -const getPRInsightsTimeSeries = `-- name: GetPRInsightsTimeSeries :many -WITH deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - cds.pull_request_state, - c.created_at - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= $1::timestamptz - AND c.created_at < $2::timestamptz - AND ($3::uuid IS NULL OR c.owner_id = $3::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - date_trunc('day', created_at)::timestamptz AS date, - COUNT(*)::bigint AS prs_created, - COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS prs_merged, - COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS prs_closed -FROM deduped -GROUP BY date_trunc('day', created_at) -ORDER BY date_trunc('day', created_at) -` - -type GetPRInsightsTimeSeriesParams struct { - StartDate time.Time `db:"start_date" json:"start_date"` - EndDate time.Time `db:"end_date" json:"end_date"` - OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` -} - -type GetPRInsightsTimeSeriesRow struct { - Date time.Time `db:"date" json:"date"` - PrsCreated int64 `db:"prs_created" json:"prs_created"` - PrsMerged int64 `db:"prs_merged" json:"prs_merged"` - PrsClosed int64 `db:"prs_closed" json:"prs_closed"` -} - -// Returns daily PR counts grouped by state for the chart. -// Uses a CTE to deduplicate by PR URL so that multiple chats referencing -// the same pull request are only counted once (keeping the most recent chat). -func (q *sqlQuerier) GetPRInsightsTimeSeries(ctx context.Context, arg GetPRInsightsTimeSeriesParams) ([]GetPRInsightsTimeSeriesRow, error) { - rows, err := q.db.QueryContext(ctx, getPRInsightsTimeSeries, arg.StartDate, arg.EndDate, arg.OwnerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetPRInsightsTimeSeriesRow - for rows.Next() { - var i GetPRInsightsTimeSeriesRow - if err := rows.Scan( - &i.Date, - &i.PrsCreated, - &i.PrsMerged, - &i.PrsClosed, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const backfillChatModelConfigProvider = `-- name: BackfillChatModelConfigProvider :execresult UPDATE chat_model_configs diff --git a/coderd/database/queries/chatinsights.sql b/coderd/database/queries/chatinsights.sql deleted file mode 100644 index 9eda12a41a..0000000000 --- a/coderd/database/queries/chatinsights.sql +++ /dev/null @@ -1,268 +0,0 @@ --- PR Insights queries for the /agents analytics dashboard. --- These aggregate data from chat_diff_statuses (PR metadata) joined --- with chats and chat_messages (cost) to power the PR Insights view. --- --- Cost is computed per PR by summing the PR-linked chat's own cost plus --- the costs of any direct children (subagents) it spawned that do NOT --- have their own PR association. If a child chat has its own --- chat_diff_statuses entry (with a non-NULL pull_request_state), its --- cost is attributed to that child's PR instead — preventing --- double-counting when sibling chats create different PRs. --- Subagent trees are at most 2 levels deep (enforced by the --- application layer). PR metadata (state, additions, deletions) --- comes from the most recent chat via DISTINCT ON so that each PR --- is counted exactly once. - --- name: GetPRInsightsSummary :one --- Returns aggregate PR metrics for the given date range. --- The handler calls this twice (current + previous period) for trends. --- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its --- direct children (that lack their own PR), and deduped picks one row --- per PR for state/additions/deletions. -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - -- For each PR, include the chat that references it plus any - -- direct children (subagents) that do not have their own PR. - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - cds.pull_request_state, - cds.additions, - cds.deletions - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - COUNT(*)::bigint AS total_prs_created, - COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS total_prs_merged, - COUNT(*) FILTER (WHERE d.pull_request_state = 'closed')::bigint AS total_prs_closed, - COALESCE(SUM(d.additions), 0)::bigint AS total_additions, - COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions, - COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros, - COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros -FROM deduped d -JOIN pr_costs pc ON pc.pr_key = d.pr_key; - --- name: GetPRInsightsTimeSeries :many --- Returns daily PR counts grouped by state for the chart. --- Uses a CTE to deduplicate by PR URL so that multiple chats referencing --- the same pull request are only counted once (keeping the most recent chat). -WITH deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - cds.pull_request_state, - c.created_at - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - date_trunc('day', created_at)::timestamptz AS date, - COUNT(*)::bigint AS prs_created, - COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS prs_merged, - COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS prs_closed -FROM deduped -GROUP BY date_trunc('day', created_at) -ORDER BY date_trunc('day', created_at); - --- name: GetPRInsightsPerModel :many --- Returns PR metrics grouped by the model used for each chat. --- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its --- direct children (that lack their own PR), and deduped picks one row --- per PR for state/additions/deletions/model (model comes from the --- most recent chat). -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - cds.pull_request_state, - cds.additions, - cds.deletions, - cmc.id AS model_config_id, - cmc.display_name, - cmc.model, - cmc.provider - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT - d.model_config_id, - COALESCE(NULLIF(d.display_name, ''), NULLIF(d.model, ''), 'Unknown')::text AS display_name, - COALESCE(d.provider, 'unknown')::text AS provider, - COUNT(*)::bigint AS total_prs, - COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS merged_prs, - COALESCE(SUM(d.additions), 0)::bigint AS total_additions, - COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions, - COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros, - COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros -FROM deduped d -JOIN pr_costs pc ON pc.pr_key = d.pr_key -GROUP BY d.model_config_id, d.display_name, d.model, d.provider -ORDER BY total_prs DESC; - --- name: GetPRInsightsPullRequests :many --- Returns all individual PR rows with cost for the selected time range. --- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its --- direct children (that lack their own PR), and deduped picks one row --- per PR for metadata. A safety-cap LIMIT guards against unexpectedly --- large result sets from direct API callers. -WITH pr_costs AS ( - SELECT - prc.pr_key, - COALESCE(SUM(cc.cost_micros), 0) AS cost_micros - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - related.id AS chat_id - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - JOIN chats related - ON related.id = c.id - OR (related.parent_chat_id = c.id - AND NOT EXISTS ( - SELECT 1 FROM chat_diff_statuses cds2 - WHERE cds2.chat_id = related.id - AND cds2.pull_request_state IS NOT NULL - )) - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ) prc - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros - FROM chat_messages cm - WHERE cm.chat_id = prc.chat_id - AND cm.total_cost_micros IS NOT NULL - ) cc ON TRUE - GROUP BY prc.pr_key -), -deduped AS ( - SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text)) - COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key, - c.id AS chat_id, - cds.pull_request_title AS pr_title, - cds.url AS pr_url, - cds.pr_number, - cds.pull_request_state AS state, - cds.pull_request_draft AS draft, - cds.additions, - cds.deletions, - cds.changed_files, - cds.commits, - cds.approved, - cds.changes_requested, - cds.reviewer_count, - cds.author_login, - cds.author_avatar_url, - COALESCE(cds.base_branch, '')::text AS base_branch, - COALESCE(NULLIF(cmc.display_name, ''), NULLIF(cmc.model, ''), 'Unknown')::text AS model_display_name, - c.created_at - FROM chat_diff_statuses cds - JOIN chats c ON c.id = cds.chat_id - LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id - WHERE cds.pull_request_state IS NOT NULL - AND c.created_at >= @start_date::timestamptz - AND c.created_at < @end_date::timestamptz - AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid) - ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC -) -SELECT * FROM ( - SELECT - d.chat_id, - d.pr_title, - d.pr_url, - d.pr_number, - d.state, - d.draft, - d.additions, - d.deletions, - d.changed_files, - d.commits, - d.approved, - d.changes_requested, - d.reviewer_count, - d.author_login, - d.author_avatar_url, - d.base_branch, - d.model_display_name, - COALESCE(pc.cost_micros, 0)::bigint AS cost_micros, - d.created_at - FROM deduped d - JOIN pr_costs pc ON pc.pr_key = d.pr_key -) sub -ORDER BY sub.created_at DESC -LIMIT 500; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 37a29763d9..5962ef3319 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -22,7 +22,6 @@ import ( "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/sqlc-dev/pqtype" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -7622,223 +7621,6 @@ func ChatProviderAPIKeysFromDeploymentValues( return chatprovider.ProviderAPIKeys{} } -// @Summary Get PR insights -// @ID get-pr-insights -// @Security CoderSessionToken -// @Tags Chats -// @Produce json -// @Param start_date query string true "Start date (RFC3339)" -// @Param end_date query string true "End date (RFC3339)" -// @Success 200 {object} codersdk.PRInsightsResponse -// @Router /api/experimental/chats/insights/pull-requests [get] -// @x-apidocgen {"skip": true} -func (api *API) prInsights(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Admin-only endpoint. - if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - - // Parse date range. - now := time.Now() - defaultStart := now.AddDate(0, 0, -30) - - qp := r.URL.Query() - p := httpapi.NewQueryParamParser() - startDate := p.Time(qp, defaultStart, "start_date", time.RFC3339) - endDate := p.Time(qp, now, "end_date", time.RFC3339) - p.ErrorExcessParams(qp) - if len(p.Errors) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query parameters.", - Validations: p.Errors, - }) - return - } - - // Calculate previous period of equal length for trend comparison. - duration := endDate.Sub(startDate) - prevStart := startDate.Add(-duration) - - // No owner filter — admin sees all data. - ownerID := uuid.NullUUID{} - - // Run all queries in parallel. - var ( - currentSummary database.GetPRInsightsSummaryRow - previousSummary database.GetPRInsightsSummaryRow - timeSeries []database.GetPRInsightsTimeSeriesRow - byModel []database.GetPRInsightsPerModelRow - recentPRs []database.GetPRInsightsPullRequestsRow - ) - - eg, egCtx := errgroup.WithContext(ctx) - eg.SetLimit(5) - - eg.Go(func() error { - var err error - currentSummary, err = api.Database.GetPRInsightsSummary(egCtx, database.GetPRInsightsSummaryParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: ownerID, - }) - return err - }) - - eg.Go(func() error { - var err error - previousSummary, err = api.Database.GetPRInsightsSummary(egCtx, database.GetPRInsightsSummaryParams{ - StartDate: prevStart, - EndDate: startDate, - OwnerID: ownerID, - }) - return err - }) - - eg.Go(func() error { - var err error - timeSeries, err = api.Database.GetPRInsightsTimeSeries(egCtx, database.GetPRInsightsTimeSeriesParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: ownerID, - }) - return err - }) - - eg.Go(func() error { - var err error - byModel, err = api.Database.GetPRInsightsPerModel(egCtx, database.GetPRInsightsPerModelParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: ownerID, - }) - return err - }) - - eg.Go(func() error { - var err error - recentPRs, err = api.Database.GetPRInsightsPullRequests(egCtx, database.GetPRInsightsPullRequestsParams{ - StartDate: startDate, - EndDate: endDate, - OwnerID: ownerID, - }) - return err - }) - - if err := eg.Wait(); err != nil { - httpapi.InternalServerError(rw, err) - return - } - - // Build summary with computed fields. - summary := codersdk.PRInsightsSummary{ - TotalPRsCreated: currentSummary.TotalPrsCreated, - TotalPRsMerged: currentSummary.TotalPrsMerged, - TotalAdditions: currentSummary.TotalAdditions, - TotalDeletions: currentSummary.TotalDeletions, - TotalCostMicros: currentSummary.TotalCostMicros, - PrevTotalPRsCreated: previousSummary.TotalPrsCreated, - PrevTotalPRsMerged: previousSummary.TotalPrsMerged, - } - if summary.TotalPRsCreated > 0 { - summary.MergeRate = float64(summary.TotalPRsMerged) / float64(summary.TotalPRsCreated) - } - if summary.TotalPRsMerged > 0 { - summary.CostPerMergedPRMicros = currentSummary.MergedCostMicros / summary.TotalPRsMerged - } - if summary.PrevTotalPRsCreated > 0 { - summary.PrevMergeRate = float64(summary.PrevTotalPRsMerged) / float64(summary.PrevTotalPRsCreated) - } - if summary.PrevTotalPRsMerged > 0 { - summary.PrevCostPerMergedPRMicros = previousSummary.MergedCostMicros / summary.PrevTotalPRsMerged - } - - // Convert time series. - tsEntries := make([]codersdk.PRInsightsTimeSeriesEntry, 0, len(timeSeries)) - for _, ts := range timeSeries { - tsEntries = append(tsEntries, codersdk.PRInsightsTimeSeriesEntry{ - Date: ts.Date, - PRsCreated: ts.PrsCreated, - PRsMerged: ts.PrsMerged, - PRsClosed: ts.PrsClosed, - }) - } - - // Convert model breakdown. - modelEntries := make([]codersdk.PRInsightsModelBreakdown, 0, len(byModel)) - for _, m := range byModel { - entry := codersdk.PRInsightsModelBreakdown{ - ModelConfigID: m.ModelConfigID.UUID, - DisplayName: m.DisplayName, - Provider: m.Provider, - TotalPRs: m.TotalPrs, - MergedPRs: m.MergedPrs, - TotalAdditions: m.TotalAdditions, - TotalDeletions: m.TotalDeletions, - TotalCostMicros: m.TotalCostMicros, - } - if entry.TotalPRs > 0 { - entry.MergeRate = float64(entry.MergedPRs) / float64(entry.TotalPRs) - } - if entry.MergedPRs > 0 { - entry.CostPerMergedPRMicros = m.MergedCostMicros / entry.MergedPRs - } - modelEntries = append(modelEntries, entry) - } - - // Convert recent PRs. - prEntries := make([]codersdk.PRInsightsPullRequest, 0, len(recentPRs)) - for _, pr := range recentPRs { - entry := codersdk.PRInsightsPullRequest{ - ChatID: pr.ChatID, - PRTitle: pr.PrTitle, - Draft: pr.Draft, - Additions: pr.Additions, - Deletions: pr.Deletions, - ChangedFiles: pr.ChangedFiles, - ChangesRequested: pr.ChangesRequested, - BaseBranch: pr.BaseBranch, - ModelDisplayName: pr.ModelDisplayName, - CostMicros: pr.CostMicros, - CreatedAt: pr.CreatedAt, - } - if pr.PrUrl.Valid { - entry.PRURL = &pr.PrUrl.String - } - if pr.PrNumber.Valid { - entry.PRNumber = &pr.PrNumber.Int32 - } - if pr.State.Valid { - entry.State = pr.State.String - } - if pr.Commits.Valid { - entry.Commits = &pr.Commits.Int32 - } - if pr.Approved.Valid { - entry.Approved = &pr.Approved.Bool - } - if pr.ReviewerCount.Valid { - entry.ReviewerCount = &pr.ReviewerCount.Int32 - } - if pr.AuthorLogin.Valid { - entry.AuthorLogin = &pr.AuthorLogin.String - } - if pr.AuthorAvatarUrl.Valid { - entry.AuthorAvatarURL = &pr.AuthorAvatarUrl.String - } - prEntries = append(prEntries, entry) - } - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.PRInsightsResponse{ - Summary: summary, - TimeSeries: tsEntries, - ByModel: modelEntries, - PullRequests: prEntries, - }) -} - // EXPERIMENTAL: this endpoint is experimental and is subject to change. // //nolint:revive // HTTP handler writes to ResponseWriter. diff --git a/codersdk/chats.go b/codersdk/chats.go index 06ccdc1e5c..0005b5f1d1 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -3540,75 +3540,3 @@ func (c *ExperimentalClient) GetChatsByWorkspace(ctx context.Context, workspaceI var result map[uuid.UUID]uuid.UUID return result, json.NewDecoder(res.Body).Decode(&result) } - -// PRInsightsResponse is the response from the PR insights endpoint. -type PRInsightsResponse struct { - Summary PRInsightsSummary `json:"summary"` - TimeSeries []PRInsightsTimeSeriesEntry `json:"time_series"` - ByModel []PRInsightsModelBreakdown `json:"by_model"` - PullRequests []PRInsightsPullRequest `json:"recent_prs"` -} - -// PRInsightsSummary contains aggregate PR metrics for a time period, -// plus the previous period's metrics for trend calculation. -type PRInsightsSummary struct { - TotalPRsCreated int64 `json:"total_prs_created"` - TotalPRsMerged int64 `json:"total_prs_merged"` - MergeRate float64 `json:"merge_rate"` - TotalAdditions int64 `json:"total_additions"` - TotalDeletions int64 `json:"total_deletions"` - TotalCostMicros int64 `json:"total_cost_micros"` - CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"` - ApprovalRate float64 `json:"approval_rate"` - PrevTotalPRsCreated int64 `json:"prev_total_prs_created"` - PrevTotalPRsMerged int64 `json:"prev_total_prs_merged"` - PrevMergeRate float64 `json:"prev_merge_rate"` - PrevCostPerMergedPRMicros int64 `json:"prev_cost_per_merged_pr_micros"` -} - -// PRInsightsTimeSeriesEntry is a single data point in the PR -// activity time series chart. -type PRInsightsTimeSeriesEntry struct { - Date time.Time `json:"date" format:"date-time"` - PRsCreated int64 `json:"prs_created"` - PRsMerged int64 `json:"prs_merged"` - PRsClosed int64 `json:"prs_closed"` -} - -// PRInsightsModelBreakdown contains PR metrics for a single model. -type PRInsightsModelBreakdown struct { - ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"` - DisplayName string `json:"display_name"` - Provider string `json:"provider"` - TotalPRs int64 `json:"total_prs"` - MergedPRs int64 `json:"merged_prs"` - MergeRate float64 `json:"merge_rate"` - TotalAdditions int64 `json:"total_additions"` - TotalDeletions int64 `json:"total_deletions"` - TotalCostMicros int64 `json:"total_cost_micros"` - CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"` -} - -// PRInsightsPullRequest represents a single PR in the recent PRs -// table. -type PRInsightsPullRequest struct { - ChatID uuid.UUID `json:"chat_id" format:"uuid"` - PRTitle string `json:"pr_title"` - PRURL *string `json:"pr_url,omitempty"` - PRNumber *int32 `json:"pr_number,omitempty"` - State string `json:"state"` - Draft bool `json:"draft"` - Additions int32 `json:"additions"` - Deletions int32 `json:"deletions"` - ChangedFiles int32 `json:"changed_files"` - Commits *int32 `json:"commits,omitempty"` - Approved *bool `json:"approved,omitempty"` - ChangesRequested bool `json:"changes_requested"` - ReviewerCount *int32 `json:"reviewer_count,omitempty"` - AuthorLogin *string `json:"author_login,omitempty"` - AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` - BaseBranch string `json:"base_branch"` - ModelDisplayName string `json:"model_display_name"` - CostMicros int64 `json:"cost_micros"` - CreatedAt time.Time `json:"created_at" format:"date-time"` -} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f9abd8b0be..cf117d6509 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9076,219 +9076,6 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | » `[any property]` | array of string | false | | | | `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. | -## codersdk.PRInsightsModelBreakdown - -```json -{ - "cost_per_merged_pr_micros": 0, - "display_name": "string", - "merge_rate": 0, - "merged_prs": 0, - "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", - "provider": "string", - "total_additions": 0, - "total_cost_micros": 0, - "total_deletions": 0, - "total_prs": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|-----------------------------|---------|----------|--------------|-------------| -| `cost_per_merged_pr_micros` | integer | false | | | -| `display_name` | string | false | | | -| `merge_rate` | number | false | | | -| `merged_prs` | integer | false | | | -| `model_config_id` | string | false | | | -| `provider` | string | false | | | -| `total_additions` | integer | false | | | -| `total_cost_micros` | integer | false | | | -| `total_deletions` | integer | false | | | -| `total_prs` | integer | false | | | - -## codersdk.PRInsightsPullRequest - -```json -{ - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "cost_micros": 0, - "created_at": "2019-08-24T14:15:22Z", - "deletions": 0, - "draft": true, - "model_display_name": "string", - "pr_number": 0, - "pr_title": "string", - "pr_url": "string", - "reviewer_count": 0, - "state": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------|---------|----------|--------------|-------------| -| `additions` | integer | false | | | -| `approved` | boolean | false | | | -| `author_avatar_url` | string | false | | | -| `author_login` | string | false | | | -| `base_branch` | string | false | | | -| `changed_files` | integer | false | | | -| `changes_requested` | boolean | false | | | -| `chat_id` | string | false | | | -| `commits` | integer | false | | | -| `cost_micros` | integer | false | | | -| `created_at` | string | false | | | -| `deletions` | integer | false | | | -| `draft` | boolean | false | | | -| `model_display_name` | string | false | | | -| `pr_number` | integer | false | | | -| `pr_title` | string | false | | | -| `pr_url` | string | false | | | -| `reviewer_count` | integer | false | | | -| `state` | string | false | | | - -## codersdk.PRInsightsResponse - -```json -{ - "by_model": [ - { - "cost_per_merged_pr_micros": 0, - "display_name": "string", - "merge_rate": 0, - "merged_prs": 0, - "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", - "provider": "string", - "total_additions": 0, - "total_cost_micros": 0, - "total_deletions": 0, - "total_prs": 0 - } - ], - "recent_prs": [ - { - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "cost_micros": 0, - "created_at": "2019-08-24T14:15:22Z", - "deletions": 0, - "draft": true, - "model_display_name": "string", - "pr_number": 0, - "pr_title": "string", - "pr_url": "string", - "reviewer_count": 0, - "state": "string" - } - ], - "summary": { - "approval_rate": 0, - "cost_per_merged_pr_micros": 0, - "merge_rate": 0, - "prev_cost_per_merged_pr_micros": 0, - "prev_merge_rate": 0, - "prev_total_prs_created": 0, - "prev_total_prs_merged": 0, - "total_additions": 0, - "total_cost_micros": 0, - "total_deletions": 0, - "total_prs_created": 0, - "total_prs_merged": 0 - }, - "time_series": [ - { - "date": "2019-08-24T14:15:22Z", - "prs_closed": 0, - "prs_created": 0, - "prs_merged": 0 - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------------|-----------------------------------------------------------------------------------|----------|--------------|-------------| -| `by_model` | array of [codersdk.PRInsightsModelBreakdown](#codersdkprinsightsmodelbreakdown) | false | | | -| `recent_prs` | array of [codersdk.PRInsightsPullRequest](#codersdkprinsightspullrequest) | false | | | -| `summary` | [codersdk.PRInsightsSummary](#codersdkprinsightssummary) | false | | | -| `time_series` | array of [codersdk.PRInsightsTimeSeriesEntry](#codersdkprinsightstimeseriesentry) | false | | | - -## codersdk.PRInsightsSummary - -```json -{ - "approval_rate": 0, - "cost_per_merged_pr_micros": 0, - "merge_rate": 0, - "prev_cost_per_merged_pr_micros": 0, - "prev_merge_rate": 0, - "prev_total_prs_created": 0, - "prev_total_prs_merged": 0, - "total_additions": 0, - "total_cost_micros": 0, - "total_deletions": 0, - "total_prs_created": 0, - "total_prs_merged": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------------------|---------|----------|--------------|-------------| -| `approval_rate` | number | false | | | -| `cost_per_merged_pr_micros` | integer | false | | | -| `merge_rate` | number | false | | | -| `prev_cost_per_merged_pr_micros` | integer | false | | | -| `prev_merge_rate` | number | false | | | -| `prev_total_prs_created` | integer | false | | | -| `prev_total_prs_merged` | integer | false | | | -| `total_additions` | integer | false | | | -| `total_cost_micros` | integer | false | | | -| `total_deletions` | integer | false | | | -| `total_prs_created` | integer | false | | | -| `total_prs_merged` | integer | false | | | - -## codersdk.PRInsightsTimeSeriesEntry - -```json -{ - "date": "2019-08-24T14:15:22Z", - "prs_closed": 0, - "prs_created": 0, - "prs_merged": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------------|---------|----------|--------------|-------------| -| `date` | string | false | | | -| `prs_closed` | integer | false | | | -| `prs_created` | integer | false | | | -| `prs_merged` | integer | false | | | - ## codersdk.PaginatedMembersResponse ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 015faa8243..6bc523d342 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3909,18 +3909,6 @@ class ExperimentalApiMethods { return response.data; }; - getPRInsights = async (params?: { - start_date?: string; - end_date?: string; - }): Promise => { - const url = getURLWithSearchParams( - "/api/experimental/chats/insights/pull-requests", - params, - ); - const response = await this.axios.get(url); - return response.data; - }; - getChatUsageLimitConfig = async (): Promise => { const response = diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 1238bb4373..28a9c62aa7 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1899,18 +1899,6 @@ export function paginatedChatCostUsers( }; } -const prInsightsKey = (params?: { start_date?: string; end_date?: string }) => - [...chatsKey, "prInsights", params] as const; - -export const prInsights = (params?: { - start_date?: string; - end_date?: string; -}) => ({ - queryKey: prInsightsKey(params), - queryFn: () => API.experimental.getPRInsights(params), - staleTime: 60_000, -}); - export const chatUsageLimitStatusKey = [ ...chatsKey, "usageLimitStatus", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2c519f8afe..5ef2e86e3f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6184,93 +6184,6 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } -// From codersdk/chats.go -/** - * PRInsightsModelBreakdown contains PR metrics for a single model. - */ -export interface PRInsightsModelBreakdown { - readonly model_config_id: string; - readonly display_name: string; - readonly provider: string; - readonly total_prs: number; - readonly merged_prs: number; - readonly merge_rate: number; - readonly total_additions: number; - readonly total_deletions: number; - readonly total_cost_micros: number; - readonly cost_per_merged_pr_micros: number; -} - -// From codersdk/chats.go -/** - * PRInsightsPullRequest represents a single PR in the recent PRs - * table. - */ -export interface PRInsightsPullRequest { - readonly chat_id: string; - readonly pr_title: string; - readonly pr_url?: string; - readonly pr_number?: number; - readonly state: string; - readonly draft: boolean; - readonly additions: number; - readonly deletions: number; - readonly changed_files: number; - readonly commits?: number; - readonly approved?: boolean; - readonly changes_requested: boolean; - readonly reviewer_count?: number; - readonly author_login?: string; - readonly author_avatar_url?: string; - readonly base_branch: string; - readonly model_display_name: string; - readonly cost_micros: number; - readonly created_at: string; -} - -// From codersdk/chats.go -/** - * PRInsightsResponse is the response from the PR insights endpoint. - */ -export interface PRInsightsResponse { - readonly summary: PRInsightsSummary; - readonly time_series: readonly PRInsightsTimeSeriesEntry[]; - readonly by_model: readonly PRInsightsModelBreakdown[]; - readonly recent_prs: readonly PRInsightsPullRequest[]; -} - -// From codersdk/chats.go -/** - * PRInsightsSummary contains aggregate PR metrics for a time period, - * plus the previous period's metrics for trend calculation. - */ -export interface PRInsightsSummary { - readonly total_prs_created: number; - readonly total_prs_merged: number; - readonly merge_rate: number; - readonly total_additions: number; - readonly total_deletions: number; - readonly total_cost_micros: number; - readonly cost_per_merged_pr_micros: number; - readonly approval_rate: number; - readonly prev_total_prs_created: number; - readonly prev_total_prs_merged: number; - readonly prev_merge_rate: number; - readonly prev_cost_per_merged_pr_micros: number; -} - -// From codersdk/chats.go -/** - * PRInsightsTimeSeriesEntry is a single data point in the PR - * activity time series chart. - */ -export interface PRInsightsTimeSeriesEntry { - readonly date: string; - readonly prs_created: number; - readonly prs_merged: number; - readonly prs_closed: number; -} - // From codersdk/organizations.go export interface PaginatedMembersRequest { readonly limit?: number; diff --git a/site/src/pages/AgentsPage/AgentSettingsInsightsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsInsightsPage.tsx deleted file mode 100644 index c0e53a1f7f..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsInsightsPage.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import dayjs, { type Dayjs } from "dayjs"; -import { type FC, useState } from "react"; -import { useQuery } from "react-query"; -import { prInsights } from "#/api/queries/chats"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { RequirePermission } from "#/modules/permissions/RequirePermission"; -import { InsightsContent } from "./components/InsightsContent"; -import type { PRInsightsTimeRange } from "./components/PRInsightsView"; - -type TimeRangeSelection = { - timeRange: PRInsightsTimeRange; - anchor: Dayjs; -}; - -function timeRangeToDates(range: PRInsightsTimeRange, anchor: Dayjs) { - const days = Number.parseInt(range, 10); - const start = anchor.subtract(days, "day"); - return { - start_date: start.toISOString(), - end_date: anchor.toISOString(), - }; -} - -const AgentSettingsInsightsPage: FC = () => { - const { permissions } = useAuthenticated(); - - const [selection, setSelection] = useState(() => ({ - timeRange: "30d", - anchor: dayjs(), - })); - const dates = timeRangeToDates(selection.timeRange, selection.anchor); - const { data, isLoading, error } = useQuery(prInsights(dates)); - - const handleTimeRangeChange = (timeRange: PRInsightsTimeRange) => - setSelection((current) => - current.timeRange === timeRange - ? current - : { - timeRange, - anchor: dayjs(), - }, - ); - - return ( - - - - ); -}; - -export default AgentSettingsInsightsPage; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts index dd06437b3c..a69420bba4 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts @@ -10,7 +10,6 @@ const ADMIN_SETTINGS_SECTIONS = new Set([ "models", "mcp-servers", "spend", - "insights", "instructions", "experiments", "lifecycle", diff --git a/site/src/pages/AgentsPage/components/InsightsContent.tsx b/site/src/pages/AgentsPage/components/InsightsContent.tsx deleted file mode 100644 index 51d563848a..0000000000 --- a/site/src/pages/AgentsPage/components/InsightsContent.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { FC } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView"; - -interface InsightsContentProps { - data: TypesGen.PRInsightsResponse | undefined; - isLoading: boolean; - error: unknown; - timeRange: PRInsightsTimeRange; - onTimeRangeChange: (range: PRInsightsTimeRange) => void; -} - -export const InsightsContent: FC = ({ - data, - isLoading, - error, - timeRange, - onTimeRangeChange, -}) => { - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
-

- Failed to load analytics data. -

-
- ); - } - - if (!data) { - return null; - } - - return ( - - ); -}; diff --git a/site/src/pages/AgentsPage/components/PRInsightsView.stories.tsx b/site/src/pages/AgentsPage/components/PRInsightsView.stories.tsx deleted file mode 100644 index 3429097407..0000000000 --- a/site/src/pages/AgentsPage/components/PRInsightsView.stories.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import dayjs from "dayjs"; -import { useState } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; -import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView"; - -// --------------------------------------------------------------------------- -// Mock data generators -// --------------------------------------------------------------------------- - -const NOW = dayjs("2025-07-15"); - -// Deterministic PRNG so story snapshots are stable across runs. -function seededRandom(seed: number) { - let s = seed; - return () => { - s = (s * 16807) % 2147483647; - return (s - 1) / 2147483646; - }; -} - -function generateTimeSeries( - days: number, - opts: { avgCreated: number; avgMerged: number; avgClosed?: number }, - seed = 42, -): TypesGen.PRInsightsTimeSeriesEntry[] { - const rand = seededRandom(seed); - const entries: TypesGen.PRInsightsTimeSeriesEntry[] = []; - for (let i = days - 1; i >= 0; i--) { - const date = NOW.subtract(i, "day").format("YYYY-MM-DD"); - const jitter = () => Math.round((rand() - 0.3) * 3); - const created = Math.max(0, opts.avgCreated + jitter()); - const merged = Math.min(created, Math.max(0, opts.avgMerged + jitter())); - const closed = Math.max( - 0, - (opts.avgClosed ?? 0) + Math.round((rand() - 0.5) * 2), - ); - entries.push({ - date, - prs_created: created, - prs_merged: merged, - prs_closed: closed, - }); - } - return entries; -} - -const MODELS: TypesGen.PRInsightsModelBreakdown[] = [ - { - model_config_id: "m1", - display_name: "Claude Sonnet 4", - provider: "Anthropic", - total_prs: 48, - merged_prs: 35, - merge_rate: 0.73, - total_additions: 8420, - total_deletions: 2130, - total_cost_micros: 142_000_000, - cost_per_merged_pr_micros: 4_057_143, - }, - { - model_config_id: "m2", - display_name: "GPT-4.1", - provider: "OpenAI", - total_prs: 31, - merged_prs: 20, - merge_rate: 0.645, - total_additions: 5100, - total_deletions: 1340, - total_cost_micros: 98_400_000, - cost_per_merged_pr_micros: 4_920_000, - }, - { - model_config_id: "m3", - display_name: "Gemini 2.5 Pro", - provider: "Google", - total_prs: 18, - merged_prs: 14, - merge_rate: 0.778, - total_additions: 3200, - total_deletions: 890, - total_cost_micros: 41_300_000, - cost_per_merged_pr_micros: 2_950_000, - }, - { - model_config_id: "m4", - display_name: "Claude Opus 4", - provider: "Anthropic", - total_prs: 8, - merged_prs: 7, - merge_rate: 0.875, - total_additions: 2100, - total_deletions: 480, - total_cost_micros: 64_200_000, - cost_per_merged_pr_micros: 9_171_429, - }, -]; - -const PR_TITLES = [ - "fix: resolve race condition in workspace agent reconnect", - "feat: add OAuth2 PKCE support for external apps", - "refactor: extract provisioner job queue into separate package", - "fix: correct RBAC check for template version imports", - "feat: add workspace build timeline visualization", - "chore: upgrade Go to 1.24 and update dependencies", - "fix: handle nil pointer in DERP mesh coordinator", - "feat: implement workspace dormancy auto-deletion policy", - "fix: prevent duplicate agent stats insertion on restart", - "feat: add audit log entries for SSH connections", - "refactor: simplify template parameter validation logic", - "fix: correct timezone handling in usage stats rollup", - "feat: add support for workspace agent environment variables", - "fix: resolve flaky TestWorkspaceBuild integration test", - "feat: implement organization-scoped template policies", -]; - -const AUTHORS = [ - { - login: "kylecarbs", - avatar: "https://avatars.githubusercontent.com/u/7122116", - }, - { - login: "ammario", - avatar: "https://avatars.githubusercontent.com/u/9078713", - }, - { - login: "mafredri", - avatar: "https://avatars.githubusercontent.com/u/147409", - }, - { - login: "aslilac", - avatar: "https://avatars.githubusercontent.com/u/23068824", - }, - { - login: "sreya", - avatar: "https://avatars.githubusercontent.com/u/67369800", - }, - { - login: "mtojek", - avatar: "https://avatars.githubusercontent.com/u/14044910", - }, - { - login: "deansheather", - avatar: "https://avatars.githubusercontent.com/u/11241812", - }, -]; - -function generatePRs( - count: number, - seed = 100, -): TypesGen.PRInsightsPullRequest[] { - const rand = seededRandom(seed); - const states: Array<"open" | "closed" | "merged"> = [ - "merged", - "merged", - "merged", - "merged", - "merged", - "open", - "open", - "closed", - ]; - const models = [ - "Claude Sonnet 4", - "GPT-4.1", - "Gemini 2.5 Pro", - "Claude Opus 4", - ]; - - return Array.from({ length: count }, (_, i) => { - const state = states[i % states.length]; - const author = AUTHORS[i % AUTHORS.length]; - const additions = Math.round(40 + rand() * 400); - const deletions = Math.round(10 + rand() * 150); - - return { - chat_id: `chat-${i}`, - pr_title: PR_TITLES[i % PR_TITLES.length], - pr_url: `https://github.com/coder/coder/pull/${1200 + i}`, - pr_number: 1200 + i, - state, - draft: state === "open" && i % 3 === 0, - additions, - deletions, - changed_files: Math.round(2 + rand() * 12), - commits: Math.round(1 + rand() * 6), - approved: - state === "merged" ? true : state === "open" ? undefined : false, - changes_requested: state === "closed" && i % 2 === 0, - reviewer_count: - state === "merged" - ? Math.round(1 + rand() * 2) - : Math.round(rand() * 2), - author_login: author.login, - author_avatar_url: author.avatar, - base_branch: "main", - model_display_name: models[i % models.length], - cost_micros: Math.round(1_500_000 + rand() * 8_000_000), - created_at: NOW.subtract( - i * 4 + Math.round(rand() * 8), - "hour", - ).toISOString(), - }; - }); -} - -// --------------------------------------------------------------------------- -// Assembled mock datasets -// --------------------------------------------------------------------------- - -const defaultData: TypesGen.PRInsightsResponse = { - summary: { - total_prs_created: 105, - total_prs_merged: 76, - merge_rate: 0.724, - total_additions: 18820, - total_deletions: 4840, - total_cost_micros: 346_000_000, - cost_per_merged_pr_micros: 4_552_632, - approval_rate: 0.88, - prev_total_prs_created: 82, - prev_total_prs_merged: 55, - prev_merge_rate: 0.671, - prev_cost_per_merged_pr_micros: 5_120_000, - }, - time_series: generateTimeSeries(30, { - avgCreated: 4, - avgMerged: 3, - avgClosed: 1, - }), - by_model: MODELS, - recent_prs: generatePRs(12), -}; - -const highPerformanceData: TypesGen.PRInsightsResponse = { - summary: { - total_prs_created: 210, - total_prs_merged: 189, - merge_rate: 0.9, - total_additions: 42_600, - total_deletions: 11_200, - total_cost_micros: 520_000_000, - cost_per_merged_pr_micros: 2_751_323, - approval_rate: 0.95, - prev_total_prs_created: 140, - prev_total_prs_merged: 112, - prev_merge_rate: 0.8, - prev_cost_per_merged_pr_micros: 3_400_000, - }, - time_series: generateTimeSeries(30, { - avgCreated: 7, - avgMerged: 6, - avgClosed: 1, - }), - by_model: MODELS.map((m) => ({ - ...m, - merge_rate: Math.min(m.merge_rate + 0.12, 0.98), - total_prs: m.total_prs * 2, - merged_prs: Math.round(m.merged_prs * 2.4), - })), - recent_prs: generatePRs(15), -}; - -const lowVolumeData: TypesGen.PRInsightsResponse = { - summary: { - total_prs_created: 8, - total_prs_merged: 3, - merge_rate: 0.375, - total_additions: 620, - total_deletions: 180, - total_cost_micros: 18_000_000, - cost_per_merged_pr_micros: 6_000_000, - approval_rate: 0.67, - prev_total_prs_created: 12, - prev_total_prs_merged: 7, - prev_merge_rate: 0.583, - prev_cost_per_merged_pr_micros: 4_200_000, - }, - time_series: generateTimeSeries(30, { avgCreated: 0, avgMerged: 0 }), - by_model: MODELS.slice(0, 2).map((m, i) => ({ - ...m, - total_prs: Math.round(m.total_prs / 6), - merged_prs: Math.round(m.merged_prs / 8), - merge_rate: 0.35 + i * 0.07, - })), - recent_prs: generatePRs(5), -}; - -// --------------------------------------------------------------------------- -// Stories -// --------------------------------------------------------------------------- - -const meta: Meta = { - title: "pages/AgentsPage/PRInsightsView", - component: PRInsightsView, - decorators: [ - (Story) => ( -
- -
- ), - ], - render: function Render(args) { - const [timeRange, setTimeRange] = useState( - args.timeRange, - ); - return ( - - ); - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - data: defaultData, - timeRange: "30d", - onTimeRangeChange: () => {}, - }, -}; - -export const HighPerformance: Story = { - args: { - data: highPerformanceData, - timeRange: "30d", - onTimeRangeChange: () => {}, - }, -}; - -export const LowVolume: Story = { - args: { - data: lowVolumeData, - timeRange: "14d", - onTimeRangeChange: () => {}, - }, -}; - -export const NoPRs: Story = { - args: { - data: { - summary: { - total_prs_created: 0, - total_prs_merged: 0, - merge_rate: 0, - total_additions: 0, - total_deletions: 0, - total_cost_micros: 0, - cost_per_merged_pr_micros: 0, - approval_rate: 0, - prev_total_prs_created: 0, - prev_total_prs_merged: 0, - prev_merge_rate: 0, - prev_cost_per_merged_pr_micros: 0, - }, - time_series: generateTimeSeries(30, { - avgCreated: 0, - avgMerged: 0, - avgClosed: 0, - }), - by_model: [], - recent_prs: [], - }, - timeRange: "30d", - onTimeRangeChange: () => {}, - }, -}; diff --git a/site/src/pages/AgentsPage/components/PRInsightsView.tsx b/site/src/pages/AgentsPage/components/PRInsightsView.tsx deleted file mode 100644 index 2d6a26ba9b..0000000000 --- a/site/src/pages/AgentsPage/components/PRInsightsView.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { CodeIcon, ExternalLinkIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import type * as TypesGen from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "#/components/Chart/Chart"; -import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { cn } from "#/utils/cn"; -import { formatCostMicros } from "#/utils/currency"; -import { paginateItems } from "#/utils/paginateItems"; -import { PrStateIcon } from "./GitPanel/GitPanel"; - -dayjs.extend(relativeTime); - -// --------------------------------------------------------------------------- -// Component props -// --------------------------------------------------------------------------- - -export type PRInsightsTimeRange = "7d" | "14d" | "30d" | "90d"; - -interface PRInsightsViewProps { - data: TypesGen.PRInsightsResponse; - timeRange: PRInsightsTimeRange; - onTimeRangeChange: (range: PRInsightsTimeRange) => void; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatMergeRate(rate: number): string { - return `${Math.round(rate * 100)}%`; -} - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -const StatCard: FC<{ - label: string; - value: string; - detail?: string; -}> = ({ label, value, detail }) => ( -
-

{label}

-
-

- {value} -

-

- {detail ?? "\u00A0"} -

-
-
-); - -const prStateBadgeStyles: Record = { - merged: "text-git-merged-bright ring-current/20", - closed: "text-git-deleted-bright ring-current/20", - open: "text-git-added-bright ring-current/20", - draft: "text-content-secondary ring-border-default", -}; - -const prStateLabels: Record = { - merged: "Merged", - closed: "Closed", - open: "Open", - draft: "Draft", -}; - -function prStateKey(state: string, draft: boolean): string { - if (state === "merged" || state === "closed") return state; - return draft ? "draft" : "open"; -} - -const PRStateBadge: FC<{ state: string; draft: boolean }> = ({ - state, - draft, -}) => { - const key = prStateKey(state, draft); - - return ( - - - {prStateLabels[key] ?? "Open"} - - ); -}; - -const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => ( -
-
-
-
- - {formatMergeRate(rate)} - -
-); - -// --------------------------------------------------------------------------- -// Chart configuration -// --------------------------------------------------------------------------- - -const activityChartConfig = { - prs_created: { - label: "Created", - color: "hsl(var(--content-disabled))", - }, - prs_merged: { - label: "Merged", - color: "hsl(var(--git-merged-bright))", - }, -} satisfies ChartConfig; - -function formatChartDate(dateStr: string): string { - return dayjs(dateStr).format("MMM D"); -} - -// --------------------------------------------------------------------------- -// Activity chart — simplified to created vs merged -// --------------------------------------------------------------------------- - -const ActivityChart: FC<{ - data: readonly TypesGen.PRInsightsTimeSeriesEntry[]; -}> = ({ data }) => ( - - - - - - - - - - - - - - - (v === 0 ? "" : String(v))} - /> - dayjs(v).format("ddd, MMM D")} - /> - } - /> - - - - -); - -// --------------------------------------------------------------------------- -// Empty state -// --------------------------------------------------------------------------- - -const EmptyState: FC = () => ( -
-
- -
-
-

- No pull requests yet -

-

- Pull request data will appear here once agents start shipping code. -

-
-
-); - -// --------------------------------------------------------------------------- -// Section header helper -// --------------------------------------------------------------------------- - -const SectionTitle: FC<{ children: string }> = ({ children }) => ( -

{children}

-); - -const timeRangeOptions: { value: PRInsightsTimeRange; label: string }[] = [ - { value: "7d", label: "7d" }, - { value: "14d", label: "14d" }, - { value: "30d", label: "30d" }, - { value: "90d", label: "90d" }, -]; - -const TimeRangeFilter: FC<{ - value: PRInsightsTimeRange; - onChange: (range: PRInsightsTimeRange) => void; -}> = ({ value, onChange }) => ( -
- {timeRangeOptions.map((opt, i) => ( - - ))} -
-); - -// --------------------------------------------------------------------------- -// Main view -// --------------------------------------------------------------------------- - -const RECENT_PRS_PAGE_SIZE = 10; - -export const PRInsightsView: FC = ({ - data, - timeRange, - onTimeRangeChange, -}) => { - const { summary, time_series, by_model, recent_prs } = data; - const isEmpty = summary.total_prs_created === 0; - - // Client-side pagination for recent PRs table. - // Page resets to 1 on data refresh because the parent unmounts this - // component during loading. Clamping ensures the page is valid if the - // list shrinks without a full remount. - const [recentPrsPage, setRecentPrsPage] = useState(1); - const { - pagedItems: pagedRecentPrs, - clampedPage: clampedRecentPrsPage, - hasPreviousPage: hasRecentPrsPrev, - hasNextPage: hasRecentPrsNext, - } = paginateItems(recent_prs, RECENT_PRS_PAGE_SIZE, recentPrsPage); - - return ( -
- {/* ── Header ── */} -
-
-

- Pull Request Insights -

-

- Code changes detected by Agents. -

-
- -
- - {isEmpty ? ( - - ) : ( - <> - {/* ── Stat cards — 3 headline metrics ── */} -
- - - {" "} -
- - {/* ── Activity chart ── */} -
-
- Activity -
- {Object.entries(activityChartConfig).map(([key, cfg]) => ( -
- - - {cfg.label} - -
- ))} -
-
-
- -
-
- - {/* ── Model breakdown + Recent PRs ── */} -
- {/* ── Model performance (simplified) ── */} - {by_model.length > 0 && ( -
-
- By model -
-
- - - - Model - - Merged - - Merge rate - - Cost / merge - - - - - {by_model.map((m) => ( - - - - {m.display_name} - - - - {m.merged_prs} - - /{m.total_prs} - - - - - - - {m.merged_prs > 0 - ? formatCostMicros(m.cost_per_merged_pr_micros) - : "—"} - - - ))} - -
-
-
- )} - - {/* ── Recent pull requests (simplified) ── */} - {recent_prs.length > 0 && ( -
-
- Pull requests -
-
- - - - - - - - - - Title - Status - - Cost - - - Created - - - {" "} - - {pagedRecentPrs.map((pr) => ( - - - {" "} - - - {pr.pr_title} - - - -
- {" "} - - {pr.author_login} - · - #{pr.pr_number} -
-
- - - - - {formatCostMicros(pr.cost_micros)} - - - {dayjs(pr.created_at).format("MMM D")} - -
- ))} -
-
-
- {recent_prs.length > RECENT_PRS_PAGE_SIZE && ( -
- -
- )} -
- )} -
- - )} -
- ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index c85ccdb3d4..7a6f0cee4b 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -400,9 +400,6 @@ const AgentSettingsMCPServersPage = lazy( const AgentSettingsSpendPage = lazy( () => import("./pages/AgentsPage/AgentSettingsSpendPage"), ); -const AgentSettingsInsightsPage = lazy( - () => import("./pages/AgentsPage/AgentSettingsInsightsPage"), -); const AgentSettingsTemplatesPage = lazy( () => import("./pages/AgentsPage/AgentSettingsTemplatesPage"), ); @@ -823,7 +820,6 @@ export const router = createBrowserRouter( } /> } /> } /> - } /> } /> } />