mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+229
@@ -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": {
|
||||
|
||||
Generated
+225
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user