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;