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
+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