feat: add PR insights analytics dashboard (#23215)

## 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`
This commit is contained in:
Kyle Carberry
2026-03-18 15:29:29 +00:00
committed by GitHub
parent 4dd8531f37
commit d6fef96d72
24 changed files with 2774 additions and 45 deletions
+229
View File
@@ -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": {
+225
View File
@@ -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": {
+219
View File
@@ -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,
})
}
+3
View File
@@ -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)
+28
View File
@@ -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 {
+20
View File
@@ -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()
+32
View File
@@ -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)
+60
View File
@@ -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()
+12
View File
@@ -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)
+323
View File
@@ -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
+118
View File
@@ -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;
+72
View File
@@ -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"`
}
+4
View File
@@ -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"
-44
View File
@@ -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).
+213
View File
@@ -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
+87
View File
@@ -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;
@@ -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<AgentsSidebarProps> = (props) => {
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={WandSparklesIcon}
label="Analytics"
active={sidebarView.section === "insights"}
to="/agents/settings/insights"
replace
state={location.state}
adminOnly
/>{" "}
</>
)}
</nav>
+1 -1
View File
@@ -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;
@@ -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<PRInsightsResponse> {
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<PRInsightsTimeRange>("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 (
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size="lg" loading />
</div>
);
}
if (error) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<p className="text-sm text-content-secondary">
Failed to load analytics data.
</p>
</div>
);
}
if (!data) {
return null;
}
return (
<PRInsightsView
data={data}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
);
};
@@ -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<typeof PRInsightsView> = {
title: "pages/AgentsPage/PRInsightsView",
component: PRInsightsView,
decorators: [
(Story) => (
<div className="bg-surface-primary p-6" style={{ maxWidth: 1100 }}>
<Story />
</div>
),
],
render: function Render(args) {
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>(
args.timeRange,
);
return (
<PRInsightsView
{...args}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof PRInsightsView>;
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: () => {},
},
};
@@ -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 (
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-green px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-success">
<ArrowUpRightIcon className="size-3" />
{formatPct(change)}
</span>
);
}
if (isNegative) {
return (
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-red px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-destructive">
<ArrowDownRightIcon className="size-3" />
{formatPct(change)}
</span>
);
}
return (
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-secondary">
0%
</span>
);
};
const StatCard: FC<{
label: string;
value: string;
trend?: React.ReactNode;
detail?: string;
}> = ({ label, value, trend, detail }) => (
<div className="flex flex-col justify-between rounded-lg border border-border-default bg-surface-primary p-5">
<p className="m-0 text-[13px] text-content-secondary">{label}</p>
<div className="mt-2">
<div className="flex items-baseline gap-2">
<p className="m-0 text-[28px] font-semibold leading-none tracking-tight text-content-primary">
{value}
</p>
{trend}
</div>
{detail && (
<p className="m-0 mt-1.5 text-xs text-content-disabled">{detail}</p>
)}
</div>
</div>
);
const prStateBadgeStyles: Record<string, string> = {
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<string, string> = {
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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
prStateBadgeStyles[key] ?? prStateBadgeStyles.open,
)}
>
<PrStateIcon state={state} draft={draft} className="size-3" />
{prStateLabels[key] ?? "Open"}
</span>
);
};
const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => (
<div className="flex items-center gap-2.5">
<div className="h-[6px] w-20 overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-git-merged-bright transition-all"
style={{ width: `${Math.round(rate * 100)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-content-primary">
{formatMergeRate(rate)}
</span>
</div>
);
// ---------------------------------------------------------------------------
// 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 }) => (
<ChartContainer config={activityChartConfig} className="aspect-auto h-full">
<AreaChart
accessibilityLayer
data={[...data]}
margin={{ top: 8, left: -8, right: 8, bottom: 0 }}
>
<defs>
<linearGradient id="fillCreated" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-prs_created)"
stopOpacity={0.35}
/>
<stop
offset="95%"
stopColor="var(--color-prs_created)"
stopOpacity={0}
/>
</linearGradient>
<linearGradient id="fillMerged" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-prs_merged)"
stopOpacity={0.5}
/>
<stop
offset="95%"
stopColor="var(--color-prs_merged)"
stopOpacity={0.02}
/>
</linearGradient>
<linearGradient id="fillClosed" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-prs_closed)"
stopOpacity={0.35}
/>
<stop
offset="95%"
stopColor="var(--color-prs_closed)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
tickMargin={12}
minTickGap={40}
tickFormatter={formatChartDate}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={12}
allowDecimals={false}
tickFormatter={(v: number) => (v === 0 ? "" : String(v))}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(v: string) => dayjs(v).format("ddd, MMM D")}
/>
}
/>
<Area
isAnimationActive={false}
type="monotone"
dataKey="prs_created"
fill="url(#fillCreated)"
fillOpacity={1}
stroke="var(--color-prs_created)"
strokeWidth={1.5}
/>
<Area
isAnimationActive={false}
type="monotone"
dataKey="prs_merged"
fill="url(#fillMerged)"
fillOpacity={1}
stroke="var(--color-prs_merged)"
strokeWidth={2}
/>
<Area
isAnimationActive={false}
type="monotone"
dataKey="prs_closed"
fill="url(#fillClosed)"
fillOpacity={1}
stroke="var(--color-prs_closed)"
strokeWidth={1.5}
/>
</AreaChart>
</ChartContainer>
);
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
const EmptyState: FC = () => (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-surface-secondary">
<CodeIcon className="size-5 text-content-secondary" />
</div>
<div>
<p className="m-0 text-sm font-medium text-content-primary">
No pull requests yet
</p>
<p className="m-0 mt-1 text-sm text-content-secondary">
Pull request data will appear here once agents start shipping code.
</p>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Section header helper
// ---------------------------------------------------------------------------
const SectionTitle: FC<{ children: string }> = ({ children }) => (
<h3 className="m-0 text-sm font-medium text-content-primary">{children}</h3>
);
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 }) => (
<div className="inline-flex -space-x-px">
{timeRangeOptions.map((opt, i) => (
<Button
key={opt.value}
variant={value === opt.value ? "default" : "outline"}
size="sm"
onClick={() => onChange(opt.value)}
className={cn(
"min-w-0 rounded-none px-3",
i === 0 && "rounded-l-md",
i === timeRangeOptions.length - 1 && "rounded-r-md",
)}
>
{opt.label}
</Button>
))}
</div>
);
const ReviewBadge: FC<{
approved: boolean | undefined;
changes_requested: boolean;
reviewer_count: number | undefined;
}> = ({ approved, changes_requested, reviewer_count }) => {
if (!reviewer_count) {
return <span className="text-xs text-content-disabled">No reviews</span>;
}
if (approved === true && !changes_requested) {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-success">
<CheckCircle2Icon className="size-3.5" />
{reviewer_count} approved
</span>
);
}
if (changes_requested) {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-warning">
<MessageSquareTextIcon className="size-3.5" />
Changes requested
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs text-content-secondary">
<CircleDotIcon className="size-3.5" />
{reviewer_count} reviewing
</span>
);
};
// ---------------------------------------------------------------------------
// Main view
// ---------------------------------------------------------------------------
export const PRInsightsView: FC<PRInsightsViewProps> = ({
data,
timeRange,
onTimeRangeChange,
}) => {
const { summary, time_series, by_model, recent_prs } = data;
const isEmpty = summary.total_prs_created === 0;
return (
<div className="space-y-10">
{/* ── Header ── */}
<div className="flex items-end justify-between">
<div>
<h2 className="m-0 text-xl font-semibold tracking-tight text-content-primary">
Pull Request Insights
</h2>
<p className="m-0 mt-1 text-[13px] text-content-secondary">
Code shipped by AI agents across your organization.
</p>
</div>
<TimeRangeFilter value={timeRange} onChange={onTimeRangeChange} />
</div>
{isEmpty ? (
<EmptyState />
) : (
<>
{/* ── Stat cards ── */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<StatCard
label="PRs created"
value={summary.total_prs_created.toLocaleString()}
trend={
<TrendBadge
current={summary.total_prs_created}
previous={summary.prev_total_prs_created}
/>
}
/>
<StatCard
label="Merged"
value={summary.total_prs_merged.toLocaleString()}
trend={
<TrendBadge
current={summary.total_prs_merged}
previous={summary.prev_total_prs_merged}
/>
}
/>
<StatCard
label="Merge rate"
value={formatMergeRate(summary.merge_rate)}
trend={
<TrendBadge
current={summary.merge_rate}
previous={summary.prev_merge_rate}
/>
}
/>
<StatCard
label="Lines shipped"
value={formatLinesShipped(summary.total_additions)}
detail={`${formatLinesShipped(summary.total_deletions)} removed`}
/>
<StatCard
label="Cost / merged PR"
value={formatCostMicros(summary.cost_per_merged_pr_micros)}
trend={
<TrendBadge
current={summary.cost_per_merged_pr_micros}
previous={summary.prev_cost_per_merged_pr_micros}
invert
/>
}
/>
</div>
{/* ── Activity chart ── */}
<section>
<div className="mb-4 flex items-center justify-between">
<SectionTitle>Activity</SectionTitle>
<div className="flex items-center gap-5">
{Object.entries(activityChartConfig).map(([key, cfg]) => (
<div key={key} className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: cfg.color }}
/>
<span className="text-xs text-content-secondary">
{cfg.label}
</span>
</div>
))}
</div>
</div>
<div className="h-[260px] rounded-lg border border-border-default p-4 pt-2">
<ActivityChart data={time_series} />
</div>
</section>
{/* ── Model performance ── */}
{by_model.length > 0 && (
<section>
<div className="mb-4">
<SectionTitle>Performance by model</SectionTitle>
</div>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table className="text-sm">
<TableHeader>
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
<TableHead className="px-4 py-3">Model</TableHead>
<TableHead className="px-4 py-3 text-right">
PRs
</TableHead>
<TableHead className="px-4 py-3 text-right">
Merged
</TableHead>
<TableHead className="px-4 py-3">Merge rate</TableHead>
<TableHead className="px-4 py-3 text-right">
Changes
</TableHead>
<TableHead className="px-4 py-3 text-right">
Total cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cost / merge
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{by_model.map((m) => (
<TableRow
key={m.model_config_id}
className="border-t border-border-default"
>
<TableCell className="px-4 py-3">
<span className="font-medium text-content-primary">
{m.display_name}
</span>
<span className="ml-1.5 text-xs text-content-disabled">
{m.provider}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
{m.total_prs}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
{m.merged_prs}
</TableCell>
<TableCell className="px-4 py-3">
<InlineMergeBar rate={m.merge_rate} />
</TableCell>
<TableCell className="px-4 py-3 text-right">
<DiffStatBadge
additions={m.total_additions}
deletions={m.total_deletions}
/>
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
{formatCostMicros(m.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
{m.merged_prs > 0
? formatCostMicros(m.cost_per_merged_pr_micros)
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
)}
{/* ── Recent pull requests ── */}
{recent_prs.length > 0 && (
<section>
<div className="mb-4">
<SectionTitle>Recent pull requests</SectionTitle>
</div>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table className="text-sm">
<TableHeader>
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
<TableHead className="px-4 py-3">Pull request</TableHead>
<TableHead className="px-4 py-3">Status</TableHead>
<TableHead className="px-4 py-3 text-right">
Changes
</TableHead>
<TableHead className="px-4 py-3 text-right">
Reviews
</TableHead>
<TableHead className="px-4 py-3">Model</TableHead>
<TableHead className="px-4 py-3 text-right">
Cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Created
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recent_prs.map((pr) => (
<TableRow
key={pr.chat_id}
className="border-t border-border-default transition-colors hover:bg-surface-secondary/50"
>
<TableCell className="max-w-[320px] px-4 py-3">
<a
href={pr.pr_url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-start gap-1 text-sm font-medium text-content-primary no-underline hover:text-content-link"
>
<span className="truncate">{pr.pr_title}</span>
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-content-disabled opacity-0 transition-opacity group-hover:opacity-100" />
</a>
<div className="mt-1 flex items-center gap-1.5 text-xs text-content-disabled">
<img
src={pr.author_avatar_url}
alt=""
className="size-3.5 rounded-full"
/>
<span>{pr.author_login}</span>
<span>·</span>
<span className="font-mono">#{pr.pr_number}</span>
<span></span>
<span className="font-mono">{pr.base_branch}</span>
</div>
</TableCell>
<TableCell className="px-4 py-3">
<PRStateBadge state={pr.state} draft={pr.draft} />
</TableCell>
<TableCell className="px-4 py-3 text-right">
<DiffStatBadge
additions={pr.additions}
deletions={pr.deletions}
/>
<p className="m-0 mt-1 text-xs text-content-disabled">
{pr.changed_files} file
{pr.changed_files !== 1 ? "s" : ""}
</p>
</TableCell>
<TableCell className="px-4 py-3 text-right">
<ReviewBadge
approved={pr.approved}
changes_requested={pr.changes_requested}
reviewer_count={pr.reviewer_count}
/>
</TableCell>{" "}
<TableCell className="px-4 py-3 text-xs text-content-secondary">
{pr.model_display_name}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
{formatCostMicros(pr.cost_micros)}
</TableCell>
<TableCell className="whitespace-nowrap px-4 py-3 text-right text-xs text-content-disabled">
{dayjs(pr.created_at).format("MMM D, h:mm A")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
)}
</>
)}
</div>
);
};
@@ -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<SettingsPageContentProps> = ({
{activeSection === "usage" && canManageChatModelConfigs && (
<UsageContent now={now} />
)}
{activeSection === "insights" && canManageChatModelConfigs && (
<InsightsContent />
)}
</div>
</div>
);