From d6fef96d72ec01b8026ac753218766b940856703 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 18 Mar 2026 11:29:29 -0400 Subject: [PATCH] feat: add PR insights analytics dashboard (#23215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a new admin-only **PR Insights** page for the `/agents` analytics view — a dashboard for engineering leaders to understand code shipped by AI agents. ### Backend - `GET /api/v2/chats/insights/pull-requests` — admin-only endpoint - 4 SQL queries in `chatinsights.sql` aggregating `chat_diff_statuses` joined with chat cost data (via root chat tree rollup) - Runs 5 parallel DB queries: current summary, previous summary (for trends), time series, per-model breakdown, recent PRs - SDK types auto-generate to TypeScript ### Frontend (`PRInsightsView`) - **Stat cards**: PRs created, Merged, Merge rate, Lines shipped, Cost/merged PR — with trend badges comparing to previous period - **Activity chart**: Stacked area chart (created/merged/closed) using git color tokens (`git-added-bright`, `git-merged-bright`, `git-deleted-bright`) - **Model performance table**: Per-model PR counts, inline merge rate bars, diff stats, cost breakdown - **Recent PRs table**: Status badges, review state icons, author info, external links - **Time range filter**: 7d/14d/30d/90d button group - **4 Storybook stories**: Default, HighPerformance, LowVolume, NoPRs ### Data source All PR data comes from the existing `chat_diff_statuses` table (populated by the `gitsync.Worker` background job that polls GitHub every 120s). No new data collection required. ### Screenshot View in Storybook: `pages/AgentsPage/PRInsightsView` --- coderd/apidoc/docs.go | 229 ++++++ coderd/apidoc/swagger.json | 225 ++++++ coderd/chats.go | 219 ++++++ coderd/coderd.go | 3 + coderd/database/dbauthz/dbauthz.go | 28 + coderd/database/dbauthz/dbauthz_test.go | 20 + coderd/database/dbmetrics/querymetrics.go | 32 + coderd/database/dbmock/dbmock.go | 60 ++ ...> 000445_chat_message_runtime_ms.down.sql} | 0 ... => 000445_chat_message_runtime_ms.up.sql} | 0 coderd/database/querier.go | 12 + coderd/database/queries.sql.go | 323 +++++++++ coderd/database/queries/chatinsights.sql | 118 +++ codersdk/chats.go | 72 ++ docs/manifest.json | 4 + docs/reference/api/chats.md | 44 -- docs/reference/api/schemas.md | 213 ++++++ site/src/api/typesGenerated.ts | 87 +++ site/src/pages/AgentsPage/AgentsSidebar.tsx | 10 + site/src/pages/AgentsPage/GitPanel.tsx | 2 +- site/src/pages/AgentsPage/InsightsContent.tsx | 78 ++ .../AgentsPage/PRInsightsView.stories.tsx | 357 +++++++++ site/src/pages/AgentsPage/PRInsightsView.tsx | 679 ++++++++++++++++++ .../pages/AgentsPage/SettingsPageContent.tsx | 4 + 24 files changed, 2774 insertions(+), 45 deletions(-) rename coderd/database/migrations/{000444_chat_message_runtime_ms.down.sql => 000445_chat_message_runtime_ms.down.sql} (100%) rename coderd/database/migrations/{000444_chat_message_runtime_ms.up.sql => 000445_chat_message_runtime_ms.up.sql} (100%) create mode 100644 coderd/database/queries/chatinsights.sql create mode 100644 site/src/pages/AgentsPage/InsightsContent.tsx create mode 100644 site/src/pages/AgentsPage/PRInsightsView.stories.tsx create mode 100644 site/src/pages/AgentsPage/PRInsightsView.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 236b86dc9b..10880df29f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -481,6 +481,50 @@ const docTemplate = `{ } } }, + "/chats/insights/pull-requests": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "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" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/connectionlog": { "get": { "security": [ @@ -17108,6 +17152,191 @@ 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 2fd2e29e04..d48b20e9a3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -410,6 +410,46 @@ } } }, + "/chats/insights/pull-requests": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "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" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/connectionlog": { "get": { "security": [ @@ -15553,6 +15593,191 @@ } } }, + "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/chats.go b/coderd/chats.go index 91d07fdebb..5514d5be96 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -22,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/shopspring/decimal" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -4179,3 +4180,221 @@ func (api *API) hasEffectiveProviderAPIKey(ctx context.Context, provider databas ) return effectiveKeys.APIKey(provider.Provider) != "" } + +// @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 /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.GetPRInsightsRecentPRsRow + ) + + 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.GetPRInsightsRecentPRs(egCtx, database.GetPRInsightsRecentPRsParams{ + StartDate: startDate, + EndDate: endDate, + OwnerID: ownerID, + LimitVal: 20, + }) + 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, + 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, + RecentPRs: prEntries, + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 01856736fb..9b288ade3d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1159,6 +1159,9 @@ 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 014a9efed1..de5f2a6d27 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3159,6 +3159,34 @@ 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) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetPRInsightsRecentPRs(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 8ac4a41874..0294a0dd3a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1924,6 +1924,26 @@ 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("GetPRInsightsRecentPRs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetPRInsightsRecentPRsParams{} + dbm.EXPECT().GetPRInsightsRecentPRs(gomock.Any(), arg).Return([]database.GetPRInsightsRecentPRsRow{}, 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 762e1d0974..288f7c07e1 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1744,6 +1744,38 @@ 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) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) { + start := time.Now() + r0, r1 := m.s.GetPRInsightsRecentPRs(ctx, arg) + m.queryLatencies.WithLabelValues("GetPRInsightsRecentPRs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsRecentPRs").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 894898506c..7c60866a2c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3216,6 +3216,66 @@ 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) +} + +// GetPRInsightsRecentPRs mocks base method. +func (m *MockStore) GetPRInsightsRecentPRs(ctx context.Context, arg database.GetPRInsightsRecentPRsParams) ([]database.GetPRInsightsRecentPRsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPRInsightsRecentPRs", ctx, arg) + ret0, _ := ret[0].([]database.GetPRInsightsRecentPRsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPRInsightsRecentPRs indicates an expected call of GetPRInsightsRecentPRs. +func (mr *MockStoreMockRecorder) GetPRInsightsRecentPRs(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsRecentPRs", reflect.TypeOf((*MockStore)(nil).GetPRInsightsRecentPRs), 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/migrations/000444_chat_message_runtime_ms.down.sql b/coderd/database/migrations/000445_chat_message_runtime_ms.down.sql similarity index 100% rename from coderd/database/migrations/000444_chat_message_runtime_ms.down.sql rename to coderd/database/migrations/000445_chat_message_runtime_ms.down.sql diff --git a/coderd/database/migrations/000444_chat_message_runtime_ms.up.sql b/coderd/database/migrations/000445_chat_message_runtime_ms.up.sql similarity index 100% rename from coderd/database/migrations/000444_chat_message_runtime_ms.up.sql rename to coderd/database/migrations/000445_chat_message_runtime_ms.up.sql diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eeb24606ce..41bdf082c7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -341,6 +341,18 @@ 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. + GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error) + // Returns individual PR rows with cost for the recent PRs table. + GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, 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. + // Returns aggregate PR metrics for the given date range. + // The handler calls this twice (current + previous period) for trends. + GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error) + // Returns daily PR counts grouped by state for the chart. + 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/queries.sql.go b/coderd/database/queries.sql.go index 33720a482f..6dfe20d029 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2414,6 +2414,329 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam return i, err } +const getPRInsightsPerModel = `-- name: GetPRInsightsPerModel :many +SELECT + cmc.id AS model_config_id, + cmc.display_name, + cmc.provider, + COUNT(*)::bigint AS total_prs, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs, + COALESCE(SUM(cds.additions), 0)::bigint AS total_additions, + COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions, + COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros, + COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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) +GROUP BY cmc.id, cmc.display_name, cmc.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.UUID `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. +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 getPRInsightsRecentPRs = `-- name: GetPRInsightsRecentPRs :many +SELECT + 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(cmc.display_name, cmc.model)::text AS model_display_name, + COALESCE(cc.cost_micros, 0)::bigint AS cost_micros, + c.created_at +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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 c.created_at DESC +LIMIT $4::int +` + +type GetPRInsightsRecentPRsParams 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"` + LimitVal int32 `db:"limit_val" json:"limit_val"` +} + +type GetPRInsightsRecentPRsRow 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 individual PR rows with cost for the recent PRs table. +func (q *sqlQuerier) GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, error) { + rows, err := q.db.QueryContext(ctx, getPRInsightsRecentPRs, + arg.StartDate, + arg.EndDate, + arg.OwnerID, + arg.LimitVal, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPRInsightsRecentPRsRow + for rows.Next() { + var i GetPRInsightsRecentPRsRow + 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 + +SELECT + COUNT(*)::bigint AS total_prs_created, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS total_prs_merged, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed, + COALESCE(SUM(cds.additions), 0)::bigint AS total_additions, + COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions, + COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros, + COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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) +` + +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. +// Returns aggregate PR metrics for the given date range. +// The handler calls this twice (current + previous period) for trends. +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 +SELECT + date_trunc('day', c.created_at)::timestamptz AS date, + COUNT(*)::bigint AS prs_created, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed +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) +GROUP BY date_trunc('day', c.created_at) +ORDER BY date_trunc('day', c.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. +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 deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :exec UPDATE chat_model_configs diff --git a/coderd/database/queries/chatinsights.sql b/coderd/database/queries/chatinsights.sql new file mode 100644 index 0000000000..7cdb48097b --- /dev/null +++ b/coderd/database/queries/chatinsights.sql @@ -0,0 +1,118 @@ +-- 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. + +-- name: GetPRInsightsSummary :one +-- Returns aggregate PR metrics for the given date range. +-- The handler calls this twice (current + previous period) for trends. +SELECT + COUNT(*)::bigint AS total_prs_created, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS total_prs_merged, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed, + COALESCE(SUM(cds.additions), 0)::bigint AS total_additions, + COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions, + COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros, + COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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); + +-- name: GetPRInsightsTimeSeries :many +-- Returns daily PR counts grouped by state for the chart. +SELECT + date_trunc('day', c.created_at)::timestamptz AS date, + COUNT(*)::bigint AS prs_created, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed +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) +GROUP BY date_trunc('day', c.created_at) +ORDER BY date_trunc('day', c.created_at); + +-- name: GetPRInsightsPerModel :many +-- Returns PR metrics grouped by the model used for each chat. +SELECT + cmc.id AS model_config_id, + cmc.display_name, + cmc.provider, + COUNT(*)::bigint AS total_prs, + COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs, + COALESCE(SUM(cds.additions), 0)::bigint AS total_additions, + COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions, + COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros, + COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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) +GROUP BY cmc.id, cmc.display_name, cmc.provider +ORDER BY total_prs DESC; + +-- name: GetPRInsightsRecentPRs :many +-- Returns individual PR rows with cost for the recent PRs table. +SELECT + 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(cmc.display_name, cmc.model)::text AS model_display_name, + COALESCE(cc.cost_micros, 0)::bigint AS cost_micros, + c.created_at +FROM chat_diff_statuses cds +JOIN chats c ON c.id = cds.chat_id +JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id +LEFT JOIN ( + SELECT + COALESCE(ch.root_chat_id, ch.id) AS root_id, + COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros + FROM chat_messages cm + JOIN chats ch ON ch.id = cm.chat_id + WHERE cm.total_cost_micros IS NOT NULL + GROUP BY COALESCE(ch.root_chat_id, ch.id) +) cc ON cc.root_id = COALESCE(c.root_chat_id, c.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 c.created_at DESC +LIMIT @limit_val::int; diff --git a/codersdk/chats.go b/codersdk/chats.go index 403050c4c0..78c2377dc6 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1794,3 +1794,75 @@ func formatChatStreamResponseError(response Response) string { return fmt.Sprintf("%s: %s", message, detail) } } + +// 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"` + RecentPRs []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/manifest.json b/docs/manifest.json index a61322c70d..170de644c9 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1429,6 +1429,10 @@ "title": "Builds", "path": "./reference/api/builds.md" }, + { + "title": "Chats", + "path": "./reference/api/chats.md" + }, { "title": "Debug", "path": "./reference/api/debug.md" diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f038603c5f..026b4a31ff 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1,45 +1 @@ # Chats - -## Archive a chat - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/chats/{chat}/archive - -``` - -`POST /chats/{chat}/archive` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|-----------------------------------------------------------------|-------------|--------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | - -## Watch chat desktop - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/chats/{chat}/desktop \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /chats/{chat}/desktop` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------| -| `chat` | path | string(uuid) | true | Chat ID | - -### Responses - -| Status | Meaning | Description | Schema | -|--------|--------------------------------------------------------------------------|---------------------|--------| -| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9200650837..f5bd612447 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6014,6 +6014,219 @@ 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/typesGenerated.ts b/site/src/api/typesGenerated.ts index f86e9e92a9..ca8d4617d8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4470,6 +4470,93 @@ 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/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index 0bf2683d11..d7ae2f790f 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -51,6 +51,7 @@ import { SquarePenIcon, Trash2Icon, UserIcon, + WandSparklesIcon, } from "lucide-react"; import { UserDropdownContent } from "modules/dashboard/Navbar/UserDropdown/UserDropdownContent"; import { useDashboard } from "modules/dashboard/useDashboard"; @@ -1002,6 +1003,15 @@ export const AgentsSidebar: FC = (props) => { state={location.state} adminOnly /> + {" "} )} diff --git a/site/src/pages/AgentsPage/GitPanel.tsx b/site/src/pages/AgentsPage/GitPanel.tsx index 829c33fb87..326834c48a 100644 --- a/site/src/pages/AgentsPage/GitPanel.tsx +++ b/site/src/pages/AgentsPage/GitPanel.tsx @@ -409,7 +409,7 @@ const RepoHeader: FC<{ // PR state icon (compact, for the tab bar) // --------------------------------------------------------------- -const PrStateIcon: FC<{ +export const PrStateIcon: FC<{ state?: string; draft?: boolean; className?: string; diff --git a/site/src/pages/AgentsPage/InsightsContent.tsx b/site/src/pages/AgentsPage/InsightsContent.tsx new file mode 100644 index 0000000000..8b72276375 --- /dev/null +++ b/site/src/pages/AgentsPage/InsightsContent.tsx @@ -0,0 +1,78 @@ +import type { PRInsightsResponse } from "api/typesGenerated"; +import { Spinner } from "components/Spinner/Spinner"; +import dayjs from "dayjs"; +import { type FC, useCallback, useMemo, useState } from "react"; +import { useQuery } from "react-query"; +import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView"; + +function timeRangeToDates(range: PRInsightsTimeRange) { + const end = dayjs(); + const days = Number.parseInt(range, 10); + const start = end.subtract(days, "day"); + return { + start_date: start.toISOString(), + end_date: end.toISOString(), + }; +} + +async function fetchPRInsights( + startDate: string, + endDate: string, +): Promise { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + }); + const resp = await fetch( + `/api/v2/chats/insights/pull-requests?${params.toString()}`, + ); + if (!resp.ok) { + throw new Error(`Failed to fetch PR insights: ${resp.statusText}`); + } + return resp.json(); +} + +export const InsightsContent: FC = () => { + const [timeRange, setTimeRange] = useState("30d"); + const dates = useMemo(() => timeRangeToDates(timeRange), [timeRange]); + + const { data, isLoading, error } = useQuery({ + queryKey: ["prInsights", dates.start_date, dates.end_date], + queryFn: () => fetchPRInsights(dates.start_date, dates.end_date), + }); + + const handleTimeRangeChange = useCallback( + (range: PRInsightsTimeRange) => setTimeRange(range), + [], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

+ Failed to load analytics data. +

+
+ ); + } + + if (!data) { + return null; + } + + return ( + + ); +}; diff --git a/site/src/pages/AgentsPage/PRInsightsView.stories.tsx b/site/src/pages/AgentsPage/PRInsightsView.stories.tsx new file mode 100644 index 0000000000..cc3a463907 --- /dev/null +++ b/site/src/pages/AgentsPage/PRInsightsView.stories.tsx @@ -0,0 +1,357 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as TypesGen from "api/typesGenerated"; +import dayjs from "dayjs"; +import { useState } from "react"; +import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView"; + +// --------------------------------------------------------------------------- +// Mock data generators +// --------------------------------------------------------------------------- + +const NOW = dayjs("2025-07-15"); + +function generateTimeSeries( + days: number, + opts: { avgCreated: number; avgMerged: number; avgClosed?: number }, +): TypesGen.PRInsightsTimeSeriesEntry[] { + 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((Math.random() - 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((Math.random() - 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): TypesGen.PRInsightsPullRequest[] { + 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 + Math.random() * 400); + const deletions = Math.round(10 + Math.random() * 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 + Math.random() * 12), + commits: Math.round(1 + Math.random() * 6), + approved: + state === "merged" ? true : state === "open" ? undefined : false, + changes_requested: state === "closed" && i % 2 === 0, + reviewer_count: + state === "merged" + ? Math.round(1 + Math.random() * 2) + : Math.round(Math.random() * 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 + Math.random() * 8_000_000), + created_at: NOW.subtract( + i * 4 + Math.round(Math.random() * 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) => ({ + ...m, + total_prs: Math.round(m.total_prs / 6), + merged_prs: Math.round(m.merged_prs / 8), + merge_rate: 0.35 + Math.random() * 0.15, + })), + 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/PRInsightsView.tsx b/site/src/pages/AgentsPage/PRInsightsView.tsx new file mode 100644 index 0000000000..b238ff4bde --- /dev/null +++ b/site/src/pages/AgentsPage/PRInsightsView.tsx @@ -0,0 +1,679 @@ +import type * as TypesGen from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "components/Chart/Chart"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + ArrowDownRightIcon, + ArrowUpRightIcon, + CheckCircle2Icon, + CircleDotIcon, + CodeIcon, + ExternalLinkIcon, + MessageSquareTextIcon, +} from "lucide-react"; +import type { FC } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { cn } from "utils/cn"; +import { formatCostMicros } from "utils/currency"; +import { DiffStatBadge } from "./DiffStats"; +import { PrStateIcon } from "./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 pctChange(current: number, previous: number): number | null { + if (previous === 0) return current > 0 ? 100 : null; + return ((current - previous) / previous) * 100; +} + +function formatPct(value: number): string { + return `${value >= 0 ? "+" : ""}${Math.round(value)}%`; +} + +function formatMergeRate(rate: number): string { + return `${Math.round(rate * 100)}%`; +} + +function formatLinesShipped(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +const TrendBadge: FC<{ + current: number; + previous: number; + invert?: boolean; +}> = ({ current, previous, invert = false }) => { + const change = pctChange(current, previous); + if (change === null) return null; + + const isPositive = invert ? change < 0 : change > 0; + const isNegative = invert ? change > 0 : change < 0; + + if (isPositive) { + return ( + + + {formatPct(change)} + + ); + } + if (isNegative) { + return ( + + + {formatPct(change)} + + ); + } + return ( + + 0% + + ); +}; + +const StatCard: FC<{ + label: string; + value: string; + trend?: React.ReactNode; + detail?: string; +}> = ({ label, value, trend, detail }) => ( +
+

{label}

+
+
+

+ {value} +

+ {trend} +
+ {detail && ( +

{detail}

+ )} +
+
+); + +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(--git-added-bright))", + }, + prs_merged: { + label: "Merged", + color: "hsl(var(--git-merged-bright))", + }, + prs_closed: { + label: "Closed", + color: "hsl(var(--git-deleted-bright))", + }, +} satisfies ChartConfig; + +function formatChartDate(dateStr: string): string { + return dayjs(dateStr).format("MMM D"); +} + +// --------------------------------------------------------------------------- +// Activity chart +// --------------------------------------------------------------------------- + +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) => ( + + ))} +
+); + +const ReviewBadge: FC<{ + approved: boolean | undefined; + changes_requested: boolean; + reviewer_count: number | undefined; +}> = ({ approved, changes_requested, reviewer_count }) => { + if (!reviewer_count) { + return No reviews; + } + + if (approved === true && !changes_requested) { + return ( + + + {reviewer_count} approved + + ); + } + + if (changes_requested) { + return ( + + + Changes requested + + ); + } + + return ( + + + {reviewer_count} reviewing + + ); +}; + +// --------------------------------------------------------------------------- +// Main view +// --------------------------------------------------------------------------- + +export const PRInsightsView: FC = ({ + data, + timeRange, + onTimeRangeChange, +}) => { + const { summary, time_series, by_model, recent_prs } = data; + const isEmpty = summary.total_prs_created === 0; + + return ( +
+ {/* ── Header ── */} +
+
+

+ Pull Request Insights +

+

+ Code shipped by AI agents across your organization. +

+
+ +
+ + {isEmpty ? ( + + ) : ( + <> + {/* ── Stat cards ── */} +
+ + } + /> + + } + /> + + } + /> + + + } + /> +
+ + {/* ── Activity chart ── */} +
+
+ Activity +
+ {Object.entries(activityChartConfig).map(([key, cfg]) => ( +
+ + + {cfg.label} + +
+ ))} +
+
+
+ +
+
+ + {/* ── Model performance ── */} + {by_model.length > 0 && ( +
+
+ Performance by model +
+
+ + + + Model + + PRs + + + Merged + + Merge rate + + Changes + + + Total cost + + + Cost / merge + + + + + {by_model.map((m) => ( + + + + {m.display_name} + + + {m.provider} + + + + {m.total_prs} + + + {m.merged_prs} + + + + + + + + + {formatCostMicros(m.total_cost_micros)} + + + {m.merged_prs > 0 + ? formatCostMicros(m.cost_per_merged_pr_micros) + : "—"} + + + ))} + +
+
+
+ )} + + {/* ── Recent pull requests ── */} + {recent_prs.length > 0 && ( +
+
+ Recent pull requests +
+
+ + + + Pull request + Status + + Changes + + + Reviews + + Model + + Cost + + + Created + + + + + {recent_prs.map((pr) => ( + + + + {pr.pr_title} + + +
+ + {pr.author_login} + · + #{pr.pr_number} + + {pr.base_branch} +
+
+ + + + + +

+ {pr.changed_files} file + {pr.changed_files !== 1 ? "s" : ""} +

+
+ + + {" "} + + {pr.model_display_name} + + + {formatCostMicros(pr.cost_micros)} + + + {dayjs(pr.created_at).format("MMM D, h:mm A")} + +
+ ))} +
+
+
+
+ )} + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index b25a2ab735..d91d908dae 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -48,6 +48,7 @@ import { formatTokenCount } from "utils/analytics"; import { formatCostMicros } from "utils/currency"; import { ChatCostSummaryView } from "./ChatCostSummaryView"; import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel"; +import { InsightsContent } from "./InsightsContent"; import { LimitsTab } from "./LimitsTab"; import { SectionHeader } from "./SectionHeader"; @@ -576,6 +577,9 @@ export const SettingsPageContent: FC = ({ {activeSection === "usage" && canManageChatModelConfigs && ( )} + {activeSection === "insights" && canManageChatModelConfigs && ( + + )}
);