mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore!: remove coder agents insights page (#26457)
Removes the coder agents PR Insights page (`/agents/settings/insights`) and all of its backend support. The page had previously been hidden and was only reachable via deep link. It had previously been hidden due to the dubious value provided in the current iteration.
This commit is contained in:
Generated
-229
@@ -295,50 +295,6 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/insights/pull-requests": {
|
||||
"get": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/models": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -21328,191 +21284,6 @@ 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
@@ -256,46 +256,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/insights/pull-requests": {
|
||||
"get": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/models": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -19460,191 +19420,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -1253,9 +1253,6 @@ 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)
|
||||
|
||||
@@ -4170,34 +4170,6 @@ 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) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetPRInsightsPullRequests(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 {
|
||||
|
||||
@@ -2911,26 +2911,6 @@ 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("GetPRInsightsPullRequests", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetPRInsightsPullRequestsParams{}
|
||||
dbm.EXPECT().GetPRInsightsPullRequests(gomock.Any(), arg).Return([]database.GetPRInsightsPullRequestsRow{}, 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
@@ -2546,38 +2546,6 @@ 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) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetPRInsightsPullRequests(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetPRInsightsPullRequests").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetPRInsightsPullRequests").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)
|
||||
|
||||
Generated
-60
@@ -4722,66 +4722,6 @@ 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)
|
||||
}
|
||||
|
||||
// GetPRInsightsPullRequests mocks base method.
|
||||
func (m *MockStore) GetPRInsightsPullRequests(ctx context.Context, arg database.GetPRInsightsPullRequestsParams) ([]database.GetPRInsightsPullRequestsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPRInsightsPullRequests", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetPRInsightsPullRequestsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPRInsightsPullRequests indicates an expected call of GetPRInsightsPullRequests.
|
||||
func (mr *MockStoreMockRecorder) GetPRInsightsPullRequests(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPRInsightsPullRequests", reflect.TypeOf((*MockStore)(nil).GetPRInsightsPullRequests), 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()
|
||||
|
||||
Generated
-36
@@ -641,42 +641,6 @@ 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.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for state/additions/deletions/model (model comes from the
|
||||
// most recent chat).
|
||||
GetPRInsightsPerModel(ctx context.Context, arg GetPRInsightsPerModelParams) ([]GetPRInsightsPerModelRow, error)
|
||||
// Returns all individual PR rows with cost for the selected time range.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for metadata. A safety-cap LIMIT guards against unexpectedly
|
||||
// large result sets from direct API callers.
|
||||
GetPRInsightsPullRequests(ctx context.Context, arg GetPRInsightsPullRequestsParams) ([]GetPRInsightsPullRequestsRow, 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.
|
||||
//
|
||||
// Cost is computed per PR by summing the PR-linked chat's own cost plus
|
||||
// the costs of any direct children (subagents) it spawned that do NOT
|
||||
// have their own PR association. If a child chat has its own
|
||||
// chat_diff_statuses entry (with a non-NULL pull_request_state), its
|
||||
// cost is attributed to that child's PR instead — preventing
|
||||
// double-counting when sibling chats create different PRs.
|
||||
// Subagent trees are at most 2 levels deep (enforced by the
|
||||
// application layer). PR metadata (state, additions, deletions)
|
||||
// comes from the most recent chat via DISTINCT ON so that each PR
|
||||
// is counted exactly once.
|
||||
// Returns aggregate PR metrics for the given date range.
|
||||
// The handler calls this twice (current + previous period) for trends.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for state/additions/deletions.
|
||||
GetPRInsightsSummary(ctx context.Context, arg GetPRInsightsSummaryParams) (GetPRInsightsSummaryRow, error)
|
||||
// Returns daily PR counts grouped by state for the chart.
|
||||
// Uses a CTE to deduplicate by PR URL so that multiple chats referencing
|
||||
// the same pull request are only counted once (keeping the most recent chat).
|
||||
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)
|
||||
|
||||
@@ -11761,569 +11761,6 @@ func TestUpsertAISeats(t *testing.T) {
|
||||
require.False(t, alreadyExists)
|
||||
}
|
||||
|
||||
func TestGetPRInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
// setupChatInfra creates a fresh database with a user, chat provider,
|
||||
// and model config. Returns the store, user ID, model config ID,
|
||||
// and org ID.
|
||||
setupChatInfra := func(t *testing.T) (database.Store, uuid.UUID, uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
org := dbgen.Organization(t, store, database.Organization{})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
|
||||
dbgen.ChatProvider(t, store, database.ChatProvider{
|
||||
Provider: "anthropic",
|
||||
DisplayName: "Anthropic",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
CentralApiKeyEnabled: true,
|
||||
})
|
||||
|
||||
mc, err := insertChatModelConfigForTest(ctx, t, store, database.InsertChatModelConfigParams{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4",
|
||||
DisplayName: "Claude 4",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, user.ID, mc.ID, org.ID
|
||||
}
|
||||
|
||||
type chatParams struct {
|
||||
Store database.Store
|
||||
UserID uuid.UUID
|
||||
ModelConfigID uuid.UUID
|
||||
OrgID uuid.UUID
|
||||
}
|
||||
|
||||
createChat := func(t *testing.T, p chatParams, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := p.Store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
OrganizationID: p.OrgID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: p.UserID,
|
||||
LastModelConfigID: p.ModelConfigID,
|
||||
Title: title,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
// insertCostMessage inserts a single assistant message with the
|
||||
// given total_cost_micros value.
|
||||
insertCostMessage := func(t *testing.T, store database.Store, chatID, userID, mcID uuid.UUID, costMicros int64) {
|
||||
t.Helper()
|
||||
_, err := store.InsertChatMessages(context.Background(), database.InsertChatMessagesParams{
|
||||
ChatID: chatID,
|
||||
CreatedBy: []uuid.UUID{userID},
|
||||
ModelConfigID: []uuid.UUID{mcID},
|
||||
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
|
||||
Content: []string{`[{"type":"text","text":"hello"}]`},
|
||||
ContentVersion: []int16{1},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
|
||||
InputTokens: []int64{0},
|
||||
OutputTokens: []int64{0},
|
||||
TotalTokens: []int64{0},
|
||||
ReasoningTokens: []int64{0},
|
||||
CacheCreationTokens: []int64{0},
|
||||
CacheReadTokens: []int64{0},
|
||||
ContextLimit: []int64{0},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{costMicros},
|
||||
RuntimeMs: []int64{0},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// linkPR associates a chat with a pull request via
|
||||
// UpsertChatDiffStatus.
|
||||
linkPR := func(t *testing.T, store database.Store, chatID uuid.UUID, prURL, state, title string, additions, deletions, changed int32) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
_, err := store.UpsertChatDiffStatus(context.Background(), database.UpsertChatDiffStatusParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: prURL, Valid: true},
|
||||
PullRequestState: sql.NullString{String: state, Valid: true},
|
||||
PullRequestTitle: title,
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
ChangedFiles: changed,
|
||||
RefreshedAt: now,
|
||||
StaleAt: now.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
startDate := time.Now().Add(-24 * time.Hour)
|
||||
endDate := time.Now().Add(time.Hour)
|
||||
noOwner := uuid.NullUUID{}
|
||||
|
||||
t.Run("MultipleChatsSamePR_CostSummed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
chatA := createChat(t, p, "chat-A")
|
||||
insertCostMessage(t, store, chatA.ID, userID, mcID, 5_000_000) // $5
|
||||
|
||||
chatB := createChat(t, p, "chat-B")
|
||||
insertCostMessage(t, store, chatB.ID, userID, mcID, 3_000_000) // $3
|
||||
|
||||
prURL := "https://github.com/org/repo/pull/123"
|
||||
linkPR(t, store, chatA.ID, prURL, "merged", "fix: something", 100, 20, 5)
|
||||
linkPR(t, store, chatB.ID, prURL, "merged", "fix: something", 100, 20, 5)
|
||||
|
||||
// Both chats reference the same PR. The pr_costs CTE sums
|
||||
// cost across all chats for the same PR URL, so the total
|
||||
// should be $5 + $3 = $8. The PR itself is counted once.
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(8_000_000), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, int64(8_000_000), recent[0].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("DifferentPRs_NoDuplication", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
chatA := createChat(t, p, "chat-A")
|
||||
insertCostMessage(t, store, chatA.ID, userID, mcID, 5_000_000)
|
||||
linkPR(t, store, chatA.ID, "https://github.com/org/repo/pull/1", "merged", "feat: A", 50, 10, 2)
|
||||
|
||||
chatB := createChat(t, p, "chat-B")
|
||||
insertCostMessage(t, store, chatB.ID, userID, mcID, 3_000_000)
|
||||
linkPR(t, store, chatB.ID, "https://github.com/org/repo/pull/2", "open", "feat: B", 80, 30, 4)
|
||||
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(8_000_000), summary.TotalCostMicros) // $5 + $3
|
||||
assert.Equal(t, int64(1), summary.TotalPrsMerged)
|
||||
|
||||
// RecentPRs ordered by created_at DESC: chatB is newer.
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
// Costs must not be mixed across different PRs.
|
||||
assert.Equal(t, int64(3_000_000), recent[0].CostMicros) // PR 2 (newer)
|
||||
assert.Equal(t, int64(5_000_000), recent[1].CostMicros) // PR 1 (older)
|
||||
})
|
||||
|
||||
// createChildChat creates a chat with ParentChatID and RootChatID
|
||||
// set, simulating a subagent/child chat in a tree.
|
||||
createChildChat := func(t *testing.T, p chatParams, parentID, rootID uuid.UUID, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := p.Store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
OrganizationID: p.OrgID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: p.UserID,
|
||||
LastModelConfigID: p.ModelConfigID,
|
||||
Title: title,
|
||||
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
||||
RootChatID: uuid.NullUUID{UUID: rootID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
t.Run("DuplicatePRUrl_CountedOnce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
prURL := "https://github.com/org/repo/pull/99"
|
||||
for i := range 3 {
|
||||
chat := createChat(t, p, fmt.Sprintf("chat-%d", i))
|
||||
insertCostMessage(t, store, chat.ID, userID, mcID, 1_000_000)
|
||||
linkPR(t, store, chat.ID, prURL, "merged", "fix: same PR", 40, 10, 3)
|
||||
}
|
||||
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsMerged)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
})
|
||||
|
||||
t.Run("ChildChatCostsIncluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Parent chat with a $5 cost.
|
||||
parent := createChat(t, p, "parent-chat")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 5_000_000)
|
||||
|
||||
// Two child chats (subagents) with $2 each. Only the parent
|
||||
// has a chat_diff_statuses entry, but the children's costs
|
||||
// should be included via the tree join.
|
||||
child1 := createChildChat(t, p, parent.ID, parent.ID, "child-1")
|
||||
insertCostMessage(t, store, child1.ID, userID, mcID, 2_000_000)
|
||||
|
||||
child2 := createChildChat(t, p, parent.ID, parent.ID, "child-2")
|
||||
insertCostMessage(t, store, child2.ID, userID, mcID, 2_000_000)
|
||||
|
||||
prURL := "https://github.com/org/repo/pull/42"
|
||||
linkPR(t, store, parent.ID, prURL, "merged", "feat: tree cost", 60, 15, 3)
|
||||
|
||||
// Summary should reflect $5 + $2 + $2 = $9 total.
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsMerged)
|
||||
assert.Equal(t, int64(9_000_000), summary.TotalCostMicros)
|
||||
|
||||
// RecentPRs should return 1 row with the full tree cost.
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, int64(9_000_000), recent[0].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("SiblingPRs_NoCrossContamination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Parent chat with $10 orchestration cost.
|
||||
parent := createChat(t, p, "parent")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000)
|
||||
|
||||
// Child C1 ($5) creates PR1.
|
||||
c1 := createChildChat(t, p, parent.ID, parent.ID, "child-1")
|
||||
insertCostMessage(t, store, c1.ID, userID, mcID, 5_000_000)
|
||||
linkPR(t, store, c1.ID, "https://github.com/org/repo/pull/10", "merged", "feat: PR1", 50, 10, 2)
|
||||
|
||||
// Child C2 ($3) creates PR2.
|
||||
c2 := createChildChat(t, p, parent.ID, parent.ID, "child-2")
|
||||
insertCostMessage(t, store, c2.ID, userID, mcID, 3_000_000)
|
||||
linkPR(t, store, c2.ID, "https://github.com/org/repo/pull/11", "open", "feat: PR2", 30, 5, 1)
|
||||
|
||||
// With direct-branch attribution:
|
||||
// PR1 cost = C1's own cost = $5 (parent NOT included — only children of C1)
|
||||
// PR2 cost = C2's own cost = $3
|
||||
// Total = $8 (no double-counting of parent or siblings)
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(8_000_000), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
// PR2 (newer) = $3, PR1 (older) = $5.
|
||||
assert.Equal(t, int64(3_000_000), recent[0].CostMicros)
|
||||
assert.Equal(t, int64(5_000_000), recent[1].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("ParentAndChildDifferentPRs_NoCrossContamination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Parent P ($10) creates PR1.
|
||||
parent := createChat(t, p, "parent")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000)
|
||||
linkPR(t, store, parent.ID, "https://github.com/org/repo/pull/20", "merged", "feat: parent PR", 80, 20, 4)
|
||||
|
||||
// Child C1 ($5) has its own PR2. Because C1 has its own
|
||||
// chat_diff_statuses entry, its cost should NOT be included
|
||||
// under PR1 — it belongs to PR2 only.
|
||||
c1 := createChildChat(t, p, parent.ID, parent.ID, "child-1")
|
||||
insertCostMessage(t, store, c1.ID, userID, mcID, 5_000_000)
|
||||
linkPR(t, store, c1.ID, "https://github.com/org/repo/pull/21", "open", "feat: child PR", 30, 5, 1)
|
||||
|
||||
// Child C2 ($2) has NO cds entry — pure subagent.
|
||||
// Its cost should be included under PR1 (the parent's PR).
|
||||
c2 := createChildChat(t, p, parent.ID, parent.ID, "child-2")
|
||||
insertCostMessage(t, store, c2.ID, userID, mcID, 2_000_000)
|
||||
|
||||
// PR1 cost = parent ($10) + C2 ($2) = $12 (C1 excluded)
|
||||
// PR2 cost = C1 ($5)
|
||||
// Total = $17 (actual spend: $10 + $5 + $2 = $17)
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(17_000_000), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
// PR2/C1 (newer) = $5, PR1/parent (older) = $12.
|
||||
assert.Equal(t, int64(5_000_000), recent[0].CostMicros)
|
||||
assert.Equal(t, int64(12_000_000), recent[1].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("EmptyURLNotCollapsed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Two chats with empty-string URLs should be treated as
|
||||
// separate PRs (NULLIF converts '' to NULL, falling back
|
||||
// to c.id::text).
|
||||
chatX := createChat(t, p, "chat-X")
|
||||
insertCostMessage(t, store, chatX.ID, userID, mcID, 4_000_000)
|
||||
linkPR(t, store, chatX.ID, "", "open", "draft: X", 10, 2, 1)
|
||||
|
||||
chatY := createChat(t, p, "chat-Y")
|
||||
insertCostMessage(t, store, chatY.ID, userID, mcID, 6_000_000)
|
||||
linkPR(t, store, chatY.ID, "", "merged", "draft: Y", 20, 5, 2)
|
||||
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(10_000_000), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
})
|
||||
|
||||
t.Run("ParentAndChildSameURL_DedupedWithCombinedCost", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Parent P ($10) links to a PR.
|
||||
parent := createChat(t, p, "parent")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000)
|
||||
|
||||
// Child C ($5) also links to the same PR URL.
|
||||
child := createChildChat(t, p, parent.ID, parent.ID, "child")
|
||||
insertCostMessage(t, store, child.ID, userID, mcID, 5_000_000)
|
||||
|
||||
prURL := "https://github.com/org/repo/pull/50"
|
||||
linkPR(t, store, parent.ID, prURL, "merged", "feat: shared PR", 70, 15, 3)
|
||||
linkPR(t, store, child.ID, prURL, "merged", "feat: shared PR", 70, 15, 3)
|
||||
|
||||
// Both parent and child have cds entries for the same URL.
|
||||
// The PR should be counted once with combined cost $10 + $5 = $15.
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(15_000_000), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, int64(15_000_000), recent[0].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("ZeroCostChat_StillCounted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// A chat linked to a PR but with NO chat_messages at all.
|
||||
// The PR should still appear with zero cost.
|
||||
chat := createChat(t, p, "zero-cost-chat")
|
||||
linkPR(t, store, chat.ID, "https://github.com/org/repo/pull/60", "open", "feat: no messages", 25, 5, 2)
|
||||
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), summary.TotalPrsCreated)
|
||||
assert.Equal(t, int64(0), summary.TotalCostMicros)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, int64(0), recent[0].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("BlankDisplayNameFallsBackToModel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, _, orgID := setupChatInfra(t)
|
||||
|
||||
const modelName = "claude-4.1"
|
||||
emptyDisplayModel, err := insertChatModelConfigForTest(context.Background(), t, store, database.InsertChatModelConfigParams{
|
||||
Provider: "anthropic",
|
||||
Model: modelName,
|
||||
DisplayName: "",
|
||||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: false,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: emptyDisplayModel.ID, OrgID: orgID}
|
||||
chat := createChat(t, p, "chat-empty-display-name")
|
||||
insertCostMessage(t, store, chat.ID, userID, emptyDisplayModel.ID, 1_000_000)
|
||||
linkPR(t, store, chat.ID, "https://github.com/org/repo/pull/72", "merged", "fix: blank display name", 10, 2, 1)
|
||||
|
||||
byModel, err := store.GetPRInsightsPerModel(context.Background(), database.GetPRInsightsPerModelParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byModel, 1)
|
||||
assert.Equal(t, modelName, byModel[0].DisplayName)
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, modelName, recent[0].ModelDisplayName)
|
||||
})
|
||||
|
||||
t.Run("MergedCostMicros_OnlyCountsMerged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Merged PR with $5 cost.
|
||||
chatMerged := createChat(t, p, "chat-merged")
|
||||
insertCostMessage(t, store, chatMerged.ID, userID, mcID, 5_000_000)
|
||||
linkPR(t, store, chatMerged.ID, "https://github.com/org/repo/pull/70", "merged", "fix: merged", 40, 10, 2)
|
||||
|
||||
// Open PR with $3 cost.
|
||||
chatOpen := createChat(t, p, "chat-open")
|
||||
insertCostMessage(t, store, chatOpen.ID, userID, mcID, 3_000_000)
|
||||
linkPR(t, store, chatOpen.ID, "https://github.com/org/repo/pull/71", "open", "feat: open", 20, 5, 1)
|
||||
|
||||
// TotalCostMicros includes both ($5 + $3 = $8), but
|
||||
// MergedCostMicros only includes the merged PR ($5).
|
||||
summary, err := store.GetPRInsightsSummary(context.Background(), database.GetPRInsightsSummaryParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(8_000_000), summary.TotalCostMicros)
|
||||
assert.Equal(t, int64(5_000_000), summary.MergedCostMicros)
|
||||
})
|
||||
|
||||
t.Run("AllPRsReturnedWithSafetyCap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID, orgID := setupChatInfra(t)
|
||||
p := chatParams{Store: store, UserID: userID, ModelConfigID: mcID, OrgID: orgID}
|
||||
|
||||
// Create 25 distinct PRs — more than the old LIMIT 20 — and
|
||||
// verify all are returned.
|
||||
const prCount = 25
|
||||
for i := range prCount {
|
||||
chat := createChat(t, p, fmt.Sprintf("chat-%d", i))
|
||||
insertCostMessage(t, store, chat.ID, userID, mcID, 1_000_000)
|
||||
linkPR(t, store, chat.ID,
|
||||
fmt.Sprintf("https://github.com/org/repo/pull/%d", 100+i),
|
||||
"merged", fmt.Sprintf("fix: pr-%d", i), 10, 2, 1)
|
||||
}
|
||||
|
||||
recent, err := store.GetPRInsightsPullRequests(context.Background(), database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, recent, prCount, "all PRs within the date range should be returned")
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatPinOrderQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
|
||||
Generated
-467
@@ -4950,473 +4950,6 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPRInsightsPerModel = `-- name: GetPRInsightsPerModel :many
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
cds.pull_request_state,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.model,
|
||||
cmc.provider
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_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 COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
d.model_config_id,
|
||||
COALESCE(NULLIF(d.display_name, ''), NULLIF(d.model, ''), 'Unknown')::text AS display_name,
|
||||
COALESCE(d.provider, 'unknown')::text AS provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(d.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key
|
||||
GROUP BY d.model_config_id, d.display_name, d.model, d.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.NullUUID `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.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for state/additions/deletions/model (model comes from the
|
||||
// most recent 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 getPRInsightsPullRequests = `-- name: GetPRInsightsPullRequests :many
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
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(NULLIF(cmc.display_name, ''), NULLIF(cmc.model, ''), 'Unknown')::text AS model_display_name,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_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 COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT chat_id, pr_title, pr_url, pr_number, state, draft, additions, deletions, changed_files, commits, approved, changes_requested, reviewer_count, author_login, author_avatar_url, base_branch, model_display_name, cost_micros, created_at FROM (
|
||||
SELECT
|
||||
d.chat_id,
|
||||
d.pr_title,
|
||||
d.pr_url,
|
||||
d.pr_number,
|
||||
d.state,
|
||||
d.draft,
|
||||
d.additions,
|
||||
d.deletions,
|
||||
d.changed_files,
|
||||
d.commits,
|
||||
d.approved,
|
||||
d.changes_requested,
|
||||
d.reviewer_count,
|
||||
d.author_login,
|
||||
d.author_avatar_url,
|
||||
d.base_branch,
|
||||
d.model_display_name,
|
||||
COALESCE(pc.cost_micros, 0)::bigint AS cost_micros,
|
||||
d.created_at
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key
|
||||
) sub
|
||||
ORDER BY sub.created_at DESC
|
||||
LIMIT 500
|
||||
`
|
||||
|
||||
type GetPRInsightsPullRequestsParams 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 GetPRInsightsPullRequestsRow 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 all individual PR rows with cost for the selected time range.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for metadata. A safety-cap LIMIT guards against unexpectedly
|
||||
// large result sets from direct API callers.
|
||||
func (q *sqlQuerier) GetPRInsightsPullRequests(ctx context.Context, arg GetPRInsightsPullRequestsParams) ([]GetPRInsightsPullRequestsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPRInsightsPullRequests, arg.StartDate, arg.EndDate, arg.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetPRInsightsPullRequestsRow
|
||||
for rows.Next() {
|
||||
var i GetPRInsightsPullRequestsRow
|
||||
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
|
||||
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
-- For each PR, include the chat that references it plus any
|
||||
-- direct children (subagents) that do not have their own PR.
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
cds.pull_request_state,
|
||||
cds.additions,
|
||||
cds.deletions
|
||||
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)
|
||||
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_prs_created,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(d.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key
|
||||
`
|
||||
|
||||
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.
|
||||
//
|
||||
// Cost is computed per PR by summing the PR-linked chat's own cost plus
|
||||
// the costs of any direct children (subagents) it spawned that do NOT
|
||||
// have their own PR association. If a child chat has its own
|
||||
// chat_diff_statuses entry (with a non-NULL pull_request_state), its
|
||||
// cost is attributed to that child's PR instead — preventing
|
||||
// double-counting when sibling chats create different PRs.
|
||||
// Subagent trees are at most 2 levels deep (enforced by the
|
||||
// application layer). PR metadata (state, additions, deletions)
|
||||
// comes from the most recent chat via DISTINCT ON so that each PR
|
||||
// is counted exactly once.
|
||||
// Returns aggregate PR metrics for the given date range.
|
||||
// The handler calls this twice (current + previous period) for trends.
|
||||
// Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
// direct children (that lack their own PR), and deduped picks one row
|
||||
// per PR for state/additions/deletions.
|
||||
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
|
||||
WITH deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
cds.pull_request_state,
|
||||
c.created_at
|
||||
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)
|
||||
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
date_trunc('day', created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM deduped
|
||||
GROUP BY date_trunc('day', created_at)
|
||||
ORDER BY date_trunc('day', 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.
|
||||
// Uses a CTE to deduplicate by PR URL so that multiple chats referencing
|
||||
// the same pull request are only counted once (keeping the most recent chat).
|
||||
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 backfillChatModelConfigProvider = `-- name: BackfillChatModelConfigProvider :execresult
|
||||
UPDATE
|
||||
chat_model_configs
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
-- 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.
|
||||
--
|
||||
-- Cost is computed per PR by summing the PR-linked chat's own cost plus
|
||||
-- the costs of any direct children (subagents) it spawned that do NOT
|
||||
-- have their own PR association. If a child chat has its own
|
||||
-- chat_diff_statuses entry (with a non-NULL pull_request_state), its
|
||||
-- cost is attributed to that child's PR instead — preventing
|
||||
-- double-counting when sibling chats create different PRs.
|
||||
-- Subagent trees are at most 2 levels deep (enforced by the
|
||||
-- application layer). PR metadata (state, additions, deletions)
|
||||
-- comes from the most recent chat via DISTINCT ON so that each PR
|
||||
-- is counted exactly once.
|
||||
|
||||
-- name: GetPRInsightsSummary :one
|
||||
-- Returns aggregate PR metrics for the given date range.
|
||||
-- The handler calls this twice (current + previous period) for trends.
|
||||
-- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
-- direct children (that lack their own PR), and deduped picks one row
|
||||
-- per PR for state/additions/deletions.
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
-- For each PR, include the chat that references it plus any
|
||||
-- direct children (subagents) that do not have their own PR.
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
cds.pull_request_state,
|
||||
cds.additions,
|
||||
cds.deletions
|
||||
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)
|
||||
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_prs_created,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(d.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key;
|
||||
|
||||
-- name: GetPRInsightsTimeSeries :many
|
||||
-- Returns daily PR counts grouped by state for the chart.
|
||||
-- Uses a CTE to deduplicate by PR URL so that multiple chats referencing
|
||||
-- the same pull request are only counted once (keeping the most recent chat).
|
||||
WITH deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
cds.pull_request_state,
|
||||
c.created_at
|
||||
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)
|
||||
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
date_trunc('day', created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM deduped
|
||||
GROUP BY date_trunc('day', created_at)
|
||||
ORDER BY date_trunc('day', created_at);
|
||||
|
||||
-- name: GetPRInsightsPerModel :many
|
||||
-- Returns PR metrics grouped by the model used for each chat.
|
||||
-- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
-- direct children (that lack their own PR), and deduped picks one row
|
||||
-- per PR for state/additions/deletions/model (model comes from the
|
||||
-- most recent chat).
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
cds.pull_request_state,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.model,
|
||||
cmc.provider
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_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 COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT
|
||||
d.model_config_id,
|
||||
COALESCE(NULLIF(d.display_name, ''), NULLIF(d.model, ''), 'Unknown')::text AS display_name,
|
||||
COALESCE(d.provider, 'unknown')::text AS provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE d.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(d.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(d.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(pc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(pc.cost_micros) FILTER (WHERE d.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key
|
||||
GROUP BY d.model_config_id, d.display_name, d.model, d.provider
|
||||
ORDER BY total_prs DESC;
|
||||
|
||||
-- name: GetPRInsightsPullRequests :many
|
||||
-- Returns all individual PR rows with cost for the selected time range.
|
||||
-- Uses two CTEs: pr_costs sums cost for the PR-linked chat and its
|
||||
-- direct children (that lack their own PR), and deduped picks one row
|
||||
-- per PR for metadata. A safety-cap LIMIT guards against unexpectedly
|
||||
-- large result sets from direct API callers.
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
prc.pr_key,
|
||||
COALESCE(SUM(cc.cost_micros), 0) AS cost_micros
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
related.id AS chat_id
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chats related
|
||||
ON related.id = c.id
|
||||
OR (related.parent_chat_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_diff_statuses cds2
|
||||
WHERE cds2.chat_id = related.id
|
||||
AND cds2.pull_request_state IS NOT NULL
|
||||
))
|
||||
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)
|
||||
) prc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
WHERE cm.chat_id = prc.chat_id
|
||||
AND cm.total_cost_micros IS NOT NULL
|
||||
) cc ON TRUE
|
||||
GROUP BY prc.pr_key
|
||||
),
|
||||
deduped AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
|
||||
COALESCE(NULLIF(cds.url, ''), c.id::text) AS pr_key,
|
||||
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(NULLIF(cmc.display_name, ''), NULLIF(cmc.model, ''), 'Unknown')::text AS model_display_name,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_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 COALESCE(NULLIF(cds.url, ''), c.id::text), c.created_at DESC, c.id DESC
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
d.chat_id,
|
||||
d.pr_title,
|
||||
d.pr_url,
|
||||
d.pr_number,
|
||||
d.state,
|
||||
d.draft,
|
||||
d.additions,
|
||||
d.deletions,
|
||||
d.changed_files,
|
||||
d.commits,
|
||||
d.approved,
|
||||
d.changes_requested,
|
||||
d.reviewer_count,
|
||||
d.author_login,
|
||||
d.author_avatar_url,
|
||||
d.base_branch,
|
||||
d.model_display_name,
|
||||
COALESCE(pc.cost_micros, 0)::bigint AS cost_micros,
|
||||
d.created_at
|
||||
FROM deduped d
|
||||
JOIN pr_costs pc ON pc.pr_key = d.pr_key
|
||||
) sub
|
||||
ORDER BY sub.created_at DESC
|
||||
LIMIT 500;
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
@@ -7622,223 +7621,6 @@ func ChatProviderAPIKeysFromDeploymentValues(
|
||||
return chatprovider.ProviderAPIKeys{}
|
||||
}
|
||||
|
||||
// @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 /api/experimental/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.GetPRInsightsPullRequestsRow
|
||||
)
|
||||
|
||||
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.GetPRInsightsPullRequests(egCtx, database.GetPRInsightsPullRequestsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
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.UUID,
|
||||
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,
|
||||
PullRequests: prEntries,
|
||||
})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // HTTP handler writes to ResponseWriter.
|
||||
|
||||
@@ -3540,75 +3540,3 @@ func (c *ExperimentalClient) GetChatsByWorkspace(ctx context.Context, workspaceI
|
||||
var result map[uuid.UUID]uuid.UUID
|
||||
return result, json.NewDecoder(res.Body).Decode(&result)
|
||||
}
|
||||
|
||||
// PRInsightsResponse is the response from the PR insights endpoint.
|
||||
type PRInsightsResponse struct {
|
||||
Summary PRInsightsSummary `json:"summary"`
|
||||
TimeSeries []PRInsightsTimeSeriesEntry `json:"time_series"`
|
||||
ByModel []PRInsightsModelBreakdown `json:"by_model"`
|
||||
PullRequests []PRInsightsPullRequest `json:"recent_prs"`
|
||||
}
|
||||
|
||||
// PRInsightsSummary contains aggregate PR metrics for a time period,
|
||||
// plus the previous period's metrics for trend calculation.
|
||||
type PRInsightsSummary struct {
|
||||
TotalPRsCreated int64 `json:"total_prs_created"`
|
||||
TotalPRsMerged int64 `json:"total_prs_merged"`
|
||||
MergeRate float64 `json:"merge_rate"`
|
||||
TotalAdditions int64 `json:"total_additions"`
|
||||
TotalDeletions int64 `json:"total_deletions"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"`
|
||||
ApprovalRate float64 `json:"approval_rate"`
|
||||
PrevTotalPRsCreated int64 `json:"prev_total_prs_created"`
|
||||
PrevTotalPRsMerged int64 `json:"prev_total_prs_merged"`
|
||||
PrevMergeRate float64 `json:"prev_merge_rate"`
|
||||
PrevCostPerMergedPRMicros int64 `json:"prev_cost_per_merged_pr_micros"`
|
||||
}
|
||||
|
||||
// PRInsightsTimeSeriesEntry is a single data point in the PR
|
||||
// activity time series chart.
|
||||
type PRInsightsTimeSeriesEntry struct {
|
||||
Date time.Time `json:"date" format:"date-time"`
|
||||
PRsCreated int64 `json:"prs_created"`
|
||||
PRsMerged int64 `json:"prs_merged"`
|
||||
PRsClosed int64 `json:"prs_closed"`
|
||||
}
|
||||
|
||||
// PRInsightsModelBreakdown contains PR metrics for a single model.
|
||||
type PRInsightsModelBreakdown struct {
|
||||
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Provider string `json:"provider"`
|
||||
TotalPRs int64 `json:"total_prs"`
|
||||
MergedPRs int64 `json:"merged_prs"`
|
||||
MergeRate float64 `json:"merge_rate"`
|
||||
TotalAdditions int64 `json:"total_additions"`
|
||||
TotalDeletions int64 `json:"total_deletions"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
CostPerMergedPRMicros int64 `json:"cost_per_merged_pr_micros"`
|
||||
}
|
||||
|
||||
// PRInsightsPullRequest represents a single PR in the recent PRs
|
||||
// table.
|
||||
type PRInsightsPullRequest struct {
|
||||
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRURL *string `json:"pr_url,omitempty"`
|
||||
PRNumber *int32 `json:"pr_number,omitempty"`
|
||||
State string `json:"state"`
|
||||
Draft bool `json:"draft"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
Commits *int32 `json:"commits,omitempty"`
|
||||
Approved *bool `json:"approved,omitempty"`
|
||||
ChangesRequested bool `json:"changes_requested"`
|
||||
ReviewerCount *int32 `json:"reviewer_count,omitempty"`
|
||||
AuthorLogin *string `json:"author_login,omitempty"`
|
||||
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
ModelDisplayName string `json:"model_display_name"`
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
}
|
||||
|
||||
Generated
-213
@@ -9076,219 +9076,6 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| » `[any property]` | array of string | false | | |
|
||||
| `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. |
|
||||
|
||||
## codersdk.PRInsightsModelBreakdown
|
||||
|
||||
```json
|
||||
{
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"display_name": "string",
|
||||
"merge_rate": 0,
|
||||
"merged_prs": 0,
|
||||
"model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205",
|
||||
"provider": "string",
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------------------|---------|----------|--------------|-------------|
|
||||
| `cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `merge_rate` | number | false | | |
|
||||
| `merged_prs` | integer | false | | |
|
||||
| `model_config_id` | string | false | | |
|
||||
| `provider` | string | false | | |
|
||||
| `total_additions` | integer | false | | |
|
||||
| `total_cost_micros` | integer | false | | |
|
||||
| `total_deletions` | integer | false | | |
|
||||
| `total_prs` | integer | false | | |
|
||||
|
||||
## codersdk.PRInsightsPullRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"cost_micros": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deletions": 0,
|
||||
"draft": true,
|
||||
"model_display_name": "string",
|
||||
"pr_number": 0,
|
||||
"pr_title": "string",
|
||||
"pr_url": "string",
|
||||
"reviewer_count": 0,
|
||||
"state": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|---------|----------|--------------|-------------|
|
||||
| `additions` | integer | false | | |
|
||||
| `approved` | boolean | false | | |
|
||||
| `author_avatar_url` | string | false | | |
|
||||
| `author_login` | string | false | | |
|
||||
| `base_branch` | string | false | | |
|
||||
| `changed_files` | integer | false | | |
|
||||
| `changes_requested` | boolean | false | | |
|
||||
| `chat_id` | string | false | | |
|
||||
| `commits` | integer | false | | |
|
||||
| `cost_micros` | integer | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `deletions` | integer | false | | |
|
||||
| `draft` | boolean | false | | |
|
||||
| `model_display_name` | string | false | | |
|
||||
| `pr_number` | integer | false | | |
|
||||
| `pr_title` | string | false | | |
|
||||
| `pr_url` | string | false | | |
|
||||
| `reviewer_count` | integer | false | | |
|
||||
| `state` | string | false | | |
|
||||
|
||||
## codersdk.PRInsightsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"by_model": [
|
||||
{
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"display_name": "string",
|
||||
"merge_rate": 0,
|
||||
"merged_prs": 0,
|
||||
"model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205",
|
||||
"provider": "string",
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs": 0
|
||||
}
|
||||
],
|
||||
"recent_prs": [
|
||||
{
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"cost_micros": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deletions": 0,
|
||||
"draft": true,
|
||||
"model_display_name": "string",
|
||||
"pr_number": 0,
|
||||
"pr_title": "string",
|
||||
"pr_url": "string",
|
||||
"reviewer_count": 0,
|
||||
"state": "string"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"approval_rate": 0,
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"merge_rate": 0,
|
||||
"prev_cost_per_merged_pr_micros": 0,
|
||||
"prev_merge_rate": 0,
|
||||
"prev_total_prs_created": 0,
|
||||
"prev_total_prs_merged": 0,
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs_created": 0,
|
||||
"total_prs_merged": 0
|
||||
},
|
||||
"time_series": [
|
||||
{
|
||||
"date": "2019-08-24T14:15:22Z",
|
||||
"prs_closed": 0,
|
||||
"prs_created": 0,
|
||||
"prs_merged": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|-----------------------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `by_model` | array of [codersdk.PRInsightsModelBreakdown](#codersdkprinsightsmodelbreakdown) | false | | |
|
||||
| `recent_prs` | array of [codersdk.PRInsightsPullRequest](#codersdkprinsightspullrequest) | false | | |
|
||||
| `summary` | [codersdk.PRInsightsSummary](#codersdkprinsightssummary) | false | | |
|
||||
| `time_series` | array of [codersdk.PRInsightsTimeSeriesEntry](#codersdkprinsightstimeseriesentry) | false | | |
|
||||
|
||||
## codersdk.PRInsightsSummary
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_rate": 0,
|
||||
"cost_per_merged_pr_micros": 0,
|
||||
"merge_rate": 0,
|
||||
"prev_cost_per_merged_pr_micros": 0,
|
||||
"prev_merge_rate": 0,
|
||||
"prev_total_prs_created": 0,
|
||||
"prev_total_prs_merged": 0,
|
||||
"total_additions": 0,
|
||||
"total_cost_micros": 0,
|
||||
"total_deletions": 0,
|
||||
"total_prs_created": 0,
|
||||
"total_prs_merged": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------------------|---------|----------|--------------|-------------|
|
||||
| `approval_rate` | number | false | | |
|
||||
| `cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `merge_rate` | number | false | | |
|
||||
| `prev_cost_per_merged_pr_micros` | integer | false | | |
|
||||
| `prev_merge_rate` | number | false | | |
|
||||
| `prev_total_prs_created` | integer | false | | |
|
||||
| `prev_total_prs_merged` | integer | false | | |
|
||||
| `total_additions` | integer | false | | |
|
||||
| `total_cost_micros` | integer | false | | |
|
||||
| `total_deletions` | integer | false | | |
|
||||
| `total_prs_created` | integer | false | | |
|
||||
| `total_prs_merged` | integer | false | | |
|
||||
|
||||
## codersdk.PRInsightsTimeSeriesEntry
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2019-08-24T14:15:22Z",
|
||||
"prs_closed": 0,
|
||||
"prs_created": 0,
|
||||
"prs_merged": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------|----------|--------------|-------------|
|
||||
| `date` | string | false | | |
|
||||
| `prs_closed` | integer | false | | |
|
||||
| `prs_created` | integer | false | | |
|
||||
| `prs_merged` | integer | false | | |
|
||||
|
||||
## codersdk.PaginatedMembersResponse
|
||||
|
||||
```json
|
||||
|
||||
@@ -3909,18 +3909,6 @@ class ExperimentalApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getPRInsights = async (params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<TypesGen.PRInsightsResponse> => {
|
||||
const url = getURLWithSearchParams(
|
||||
"/api/experimental/chats/insights/pull-requests",
|
||||
params,
|
||||
);
|
||||
const response = await this.axios.get<TypesGen.PRInsightsResponse>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatUsageLimitConfig =
|
||||
async (): Promise<TypesGen.ChatUsageLimitConfigResponse> => {
|
||||
const response =
|
||||
|
||||
@@ -1899,18 +1899,6 @@ export function paginatedChatCostUsers(
|
||||
};
|
||||
}
|
||||
|
||||
const prInsightsKey = (params?: { start_date?: string; end_date?: string }) =>
|
||||
[...chatsKey, "prInsights", params] as const;
|
||||
|
||||
export const prInsights = (params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}) => ({
|
||||
queryKey: prInsightsKey(params),
|
||||
queryFn: () => API.experimental.getPRInsights(params),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
export const chatUsageLimitStatusKey = [
|
||||
...chatsKey,
|
||||
"usageLimitStatus",
|
||||
|
||||
Generated
-87
@@ -6184,93 +6184,6 @@ export interface OrganizationSyncSettings {
|
||||
readonly organization_assign_default: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsModelBreakdown contains PR metrics for a single model.
|
||||
*/
|
||||
export interface PRInsightsModelBreakdown {
|
||||
readonly model_config_id: string;
|
||||
readonly display_name: string;
|
||||
readonly provider: string;
|
||||
readonly total_prs: number;
|
||||
readonly merged_prs: number;
|
||||
readonly merge_rate: number;
|
||||
readonly total_additions: number;
|
||||
readonly total_deletions: number;
|
||||
readonly total_cost_micros: number;
|
||||
readonly cost_per_merged_pr_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsPullRequest represents a single PR in the recent PRs
|
||||
* table.
|
||||
*/
|
||||
export interface PRInsightsPullRequest {
|
||||
readonly chat_id: string;
|
||||
readonly pr_title: string;
|
||||
readonly pr_url?: string;
|
||||
readonly pr_number?: number;
|
||||
readonly state: string;
|
||||
readonly draft: boolean;
|
||||
readonly additions: number;
|
||||
readonly deletions: number;
|
||||
readonly changed_files: number;
|
||||
readonly commits?: number;
|
||||
readonly approved?: boolean;
|
||||
readonly changes_requested: boolean;
|
||||
readonly reviewer_count?: number;
|
||||
readonly author_login?: string;
|
||||
readonly author_avatar_url?: string;
|
||||
readonly base_branch: string;
|
||||
readonly model_display_name: string;
|
||||
readonly cost_micros: number;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsResponse is the response from the PR insights endpoint.
|
||||
*/
|
||||
export interface PRInsightsResponse {
|
||||
readonly summary: PRInsightsSummary;
|
||||
readonly time_series: readonly PRInsightsTimeSeriesEntry[];
|
||||
readonly by_model: readonly PRInsightsModelBreakdown[];
|
||||
readonly recent_prs: readonly PRInsightsPullRequest[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsSummary contains aggregate PR metrics for a time period,
|
||||
* plus the previous period's metrics for trend calculation.
|
||||
*/
|
||||
export interface PRInsightsSummary {
|
||||
readonly total_prs_created: number;
|
||||
readonly total_prs_merged: number;
|
||||
readonly merge_rate: number;
|
||||
readonly total_additions: number;
|
||||
readonly total_deletions: number;
|
||||
readonly total_cost_micros: number;
|
||||
readonly cost_per_merged_pr_micros: number;
|
||||
readonly approval_rate: number;
|
||||
readonly prev_total_prs_created: number;
|
||||
readonly prev_total_prs_merged: number;
|
||||
readonly prev_merge_rate: number;
|
||||
readonly prev_cost_per_merged_pr_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* PRInsightsTimeSeriesEntry is a single data point in the PR
|
||||
* activity time series chart.
|
||||
*/
|
||||
export interface PRInsightsTimeSeriesEntry {
|
||||
readonly date: string;
|
||||
readonly prs_created: number;
|
||||
readonly prs_merged: number;
|
||||
readonly prs_closed: number;
|
||||
}
|
||||
|
||||
// From codersdk/organizations.go
|
||||
export interface PaginatedMembersRequest {
|
||||
readonly limit?: number;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { prInsights } from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { InsightsContent } from "./components/InsightsContent";
|
||||
import type { PRInsightsTimeRange } from "./components/PRInsightsView";
|
||||
|
||||
type TimeRangeSelection = {
|
||||
timeRange: PRInsightsTimeRange;
|
||||
anchor: Dayjs;
|
||||
};
|
||||
|
||||
function timeRangeToDates(range: PRInsightsTimeRange, anchor: Dayjs) {
|
||||
const days = Number.parseInt(range, 10);
|
||||
const start = anchor.subtract(days, "day");
|
||||
return {
|
||||
start_date: start.toISOString(),
|
||||
end_date: anchor.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const AgentSettingsInsightsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const [selection, setSelection] = useState<TimeRangeSelection>(() => ({
|
||||
timeRange: "30d",
|
||||
anchor: dayjs(),
|
||||
}));
|
||||
const dates = timeRangeToDates(selection.timeRange, selection.anchor);
|
||||
const { data, isLoading, error } = useQuery(prInsights(dates));
|
||||
|
||||
const handleTimeRangeChange = (timeRange: PRInsightsTimeRange) =>
|
||||
setSelection((current) =>
|
||||
current.timeRange === timeRange
|
||||
? current
|
||||
: {
|
||||
timeRange,
|
||||
anchor: dayjs(),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<InsightsContent
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
timeRange={selection.timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsInsightsPage;
|
||||
@@ -10,7 +10,6 @@ const ADMIN_SETTINGS_SECTIONS = new Set([
|
||||
"models",
|
||||
"mcp-servers",
|
||||
"spend",
|
||||
"insights",
|
||||
"instructions",
|
||||
"experiments",
|
||||
"lifecycle",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
||||
|
||||
interface InsightsContentProps {
|
||||
data: TypesGen.PRInsightsResponse | undefined;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
timeRange: PRInsightsTimeRange;
|
||||
onTimeRangeChange: (range: PRInsightsTimeRange) => void;
|
||||
}
|
||||
|
||||
export const InsightsContent: FC<InsightsContentProps> = ({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
timeRange,
|
||||
onTimeRangeChange,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Spinner size="lg" loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-sm text-content-secondary">
|
||||
Failed to load analytics data.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PRInsightsView
|
||||
data={data}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={onTimeRangeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,372 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = dayjs("2025-07-15");
|
||||
|
||||
// Deterministic PRNG so story snapshots are stable across runs.
|
||||
function seededRandom(seed: number) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 16807) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
function generateTimeSeries(
|
||||
days: number,
|
||||
opts: { avgCreated: number; avgMerged: number; avgClosed?: number },
|
||||
seed = 42,
|
||||
): TypesGen.PRInsightsTimeSeriesEntry[] {
|
||||
const rand = seededRandom(seed);
|
||||
const entries: TypesGen.PRInsightsTimeSeriesEntry[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = NOW.subtract(i, "day").format("YYYY-MM-DD");
|
||||
const jitter = () => Math.round((rand() - 0.3) * 3);
|
||||
const created = Math.max(0, opts.avgCreated + jitter());
|
||||
const merged = Math.min(created, Math.max(0, opts.avgMerged + jitter()));
|
||||
const closed = Math.max(
|
||||
0,
|
||||
(opts.avgClosed ?? 0) + Math.round((rand() - 0.5) * 2),
|
||||
);
|
||||
entries.push({
|
||||
date,
|
||||
prs_created: created,
|
||||
prs_merged: merged,
|
||||
prs_closed: closed,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const MODELS: TypesGen.PRInsightsModelBreakdown[] = [
|
||||
{
|
||||
model_config_id: "m1",
|
||||
display_name: "Claude Sonnet 4",
|
||||
provider: "Anthropic",
|
||||
total_prs: 48,
|
||||
merged_prs: 35,
|
||||
merge_rate: 0.73,
|
||||
total_additions: 8420,
|
||||
total_deletions: 2130,
|
||||
total_cost_micros: 142_000_000,
|
||||
cost_per_merged_pr_micros: 4_057_143,
|
||||
},
|
||||
{
|
||||
model_config_id: "m2",
|
||||
display_name: "GPT-4.1",
|
||||
provider: "OpenAI",
|
||||
total_prs: 31,
|
||||
merged_prs: 20,
|
||||
merge_rate: 0.645,
|
||||
total_additions: 5100,
|
||||
total_deletions: 1340,
|
||||
total_cost_micros: 98_400_000,
|
||||
cost_per_merged_pr_micros: 4_920_000,
|
||||
},
|
||||
{
|
||||
model_config_id: "m3",
|
||||
display_name: "Gemini 2.5 Pro",
|
||||
provider: "Google",
|
||||
total_prs: 18,
|
||||
merged_prs: 14,
|
||||
merge_rate: 0.778,
|
||||
total_additions: 3200,
|
||||
total_deletions: 890,
|
||||
total_cost_micros: 41_300_000,
|
||||
cost_per_merged_pr_micros: 2_950_000,
|
||||
},
|
||||
{
|
||||
model_config_id: "m4",
|
||||
display_name: "Claude Opus 4",
|
||||
provider: "Anthropic",
|
||||
total_prs: 8,
|
||||
merged_prs: 7,
|
||||
merge_rate: 0.875,
|
||||
total_additions: 2100,
|
||||
total_deletions: 480,
|
||||
total_cost_micros: 64_200_000,
|
||||
cost_per_merged_pr_micros: 9_171_429,
|
||||
},
|
||||
];
|
||||
|
||||
const PR_TITLES = [
|
||||
"fix: resolve race condition in workspace agent reconnect",
|
||||
"feat: add OAuth2 PKCE support for external apps",
|
||||
"refactor: extract provisioner job queue into separate package",
|
||||
"fix: correct RBAC check for template version imports",
|
||||
"feat: add workspace build timeline visualization",
|
||||
"chore: upgrade Go to 1.24 and update dependencies",
|
||||
"fix: handle nil pointer in DERP mesh coordinator",
|
||||
"feat: implement workspace dormancy auto-deletion policy",
|
||||
"fix: prevent duplicate agent stats insertion on restart",
|
||||
"feat: add audit log entries for SSH connections",
|
||||
"refactor: simplify template parameter validation logic",
|
||||
"fix: correct timezone handling in usage stats rollup",
|
||||
"feat: add support for workspace agent environment variables",
|
||||
"fix: resolve flaky TestWorkspaceBuild integration test",
|
||||
"feat: implement organization-scoped template policies",
|
||||
];
|
||||
|
||||
const AUTHORS = [
|
||||
{
|
||||
login: "kylecarbs",
|
||||
avatar: "https://avatars.githubusercontent.com/u/7122116",
|
||||
},
|
||||
{
|
||||
login: "ammario",
|
||||
avatar: "https://avatars.githubusercontent.com/u/9078713",
|
||||
},
|
||||
{
|
||||
login: "mafredri",
|
||||
avatar: "https://avatars.githubusercontent.com/u/147409",
|
||||
},
|
||||
{
|
||||
login: "aslilac",
|
||||
avatar: "https://avatars.githubusercontent.com/u/23068824",
|
||||
},
|
||||
{
|
||||
login: "sreya",
|
||||
avatar: "https://avatars.githubusercontent.com/u/67369800",
|
||||
},
|
||||
{
|
||||
login: "mtojek",
|
||||
avatar: "https://avatars.githubusercontent.com/u/14044910",
|
||||
},
|
||||
{
|
||||
login: "deansheather",
|
||||
avatar: "https://avatars.githubusercontent.com/u/11241812",
|
||||
},
|
||||
];
|
||||
|
||||
function generatePRs(
|
||||
count: number,
|
||||
seed = 100,
|
||||
): TypesGen.PRInsightsPullRequest[] {
|
||||
const rand = seededRandom(seed);
|
||||
const states: Array<"open" | "closed" | "merged"> = [
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"merged",
|
||||
"open",
|
||||
"open",
|
||||
"closed",
|
||||
];
|
||||
const models = [
|
||||
"Claude Sonnet 4",
|
||||
"GPT-4.1",
|
||||
"Gemini 2.5 Pro",
|
||||
"Claude Opus 4",
|
||||
];
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const state = states[i % states.length];
|
||||
const author = AUTHORS[i % AUTHORS.length];
|
||||
const additions = Math.round(40 + rand() * 400);
|
||||
const deletions = Math.round(10 + rand() * 150);
|
||||
|
||||
return {
|
||||
chat_id: `chat-${i}`,
|
||||
pr_title: PR_TITLES[i % PR_TITLES.length],
|
||||
pr_url: `https://github.com/coder/coder/pull/${1200 + i}`,
|
||||
pr_number: 1200 + i,
|
||||
state,
|
||||
draft: state === "open" && i % 3 === 0,
|
||||
additions,
|
||||
deletions,
|
||||
changed_files: Math.round(2 + rand() * 12),
|
||||
commits: Math.round(1 + rand() * 6),
|
||||
approved:
|
||||
state === "merged" ? true : state === "open" ? undefined : false,
|
||||
changes_requested: state === "closed" && i % 2 === 0,
|
||||
reviewer_count:
|
||||
state === "merged"
|
||||
? Math.round(1 + rand() * 2)
|
||||
: Math.round(rand() * 2),
|
||||
author_login: author.login,
|
||||
author_avatar_url: author.avatar,
|
||||
base_branch: "main",
|
||||
model_display_name: models[i % models.length],
|
||||
cost_micros: Math.round(1_500_000 + rand() * 8_000_000),
|
||||
created_at: NOW.subtract(
|
||||
i * 4 + Math.round(rand() * 8),
|
||||
"hour",
|
||||
).toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assembled mock datasets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 105,
|
||||
total_prs_merged: 76,
|
||||
merge_rate: 0.724,
|
||||
total_additions: 18820,
|
||||
total_deletions: 4840,
|
||||
total_cost_micros: 346_000_000,
|
||||
cost_per_merged_pr_micros: 4_552_632,
|
||||
approval_rate: 0.88,
|
||||
prev_total_prs_created: 82,
|
||||
prev_total_prs_merged: 55,
|
||||
prev_merge_rate: 0.671,
|
||||
prev_cost_per_merged_pr_micros: 5_120_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 4,
|
||||
avgMerged: 3,
|
||||
avgClosed: 1,
|
||||
}),
|
||||
by_model: MODELS,
|
||||
recent_prs: generatePRs(12),
|
||||
};
|
||||
|
||||
const highPerformanceData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 210,
|
||||
total_prs_merged: 189,
|
||||
merge_rate: 0.9,
|
||||
total_additions: 42_600,
|
||||
total_deletions: 11_200,
|
||||
total_cost_micros: 520_000_000,
|
||||
cost_per_merged_pr_micros: 2_751_323,
|
||||
approval_rate: 0.95,
|
||||
prev_total_prs_created: 140,
|
||||
prev_total_prs_merged: 112,
|
||||
prev_merge_rate: 0.8,
|
||||
prev_cost_per_merged_pr_micros: 3_400_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 7,
|
||||
avgMerged: 6,
|
||||
avgClosed: 1,
|
||||
}),
|
||||
by_model: MODELS.map((m) => ({
|
||||
...m,
|
||||
merge_rate: Math.min(m.merge_rate + 0.12, 0.98),
|
||||
total_prs: m.total_prs * 2,
|
||||
merged_prs: Math.round(m.merged_prs * 2.4),
|
||||
})),
|
||||
recent_prs: generatePRs(15),
|
||||
};
|
||||
|
||||
const lowVolumeData: TypesGen.PRInsightsResponse = {
|
||||
summary: {
|
||||
total_prs_created: 8,
|
||||
total_prs_merged: 3,
|
||||
merge_rate: 0.375,
|
||||
total_additions: 620,
|
||||
total_deletions: 180,
|
||||
total_cost_micros: 18_000_000,
|
||||
cost_per_merged_pr_micros: 6_000_000,
|
||||
approval_rate: 0.67,
|
||||
prev_total_prs_created: 12,
|
||||
prev_total_prs_merged: 7,
|
||||
prev_merge_rate: 0.583,
|
||||
prev_cost_per_merged_pr_micros: 4_200_000,
|
||||
},
|
||||
time_series: generateTimeSeries(30, { avgCreated: 0, avgMerged: 0 }),
|
||||
by_model: MODELS.slice(0, 2).map((m, i) => ({
|
||||
...m,
|
||||
total_prs: Math.round(m.total_prs / 6),
|
||||
merged_prs: Math.round(m.merged_prs / 8),
|
||||
merge_rate: 0.35 + i * 0.07,
|
||||
})),
|
||||
recent_prs: generatePRs(5),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof PRInsightsView> = {
|
||||
title: "pages/AgentsPage/PRInsightsView",
|
||||
component: PRInsightsView,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-surface-primary p-6" style={{ maxWidth: 1100 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: function Render(args) {
|
||||
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>(
|
||||
args.timeRange,
|
||||
);
|
||||
return (
|
||||
<PRInsightsView
|
||||
{...args}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PRInsightsView>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
data: defaultData,
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const HighPerformance: Story = {
|
||||
args: {
|
||||
data: highPerformanceData,
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const LowVolume: Story = {
|
||||
args: {
|
||||
data: lowVolumeData,
|
||||
timeRange: "14d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPRs: Story = {
|
||||
args: {
|
||||
data: {
|
||||
summary: {
|
||||
total_prs_created: 0,
|
||||
total_prs_merged: 0,
|
||||
merge_rate: 0,
|
||||
total_additions: 0,
|
||||
total_deletions: 0,
|
||||
total_cost_micros: 0,
|
||||
cost_per_merged_pr_micros: 0,
|
||||
approval_rate: 0,
|
||||
prev_total_prs_created: 0,
|
||||
prev_total_prs_merged: 0,
|
||||
prev_merge_rate: 0,
|
||||
prev_cost_per_merged_pr_micros: 0,
|
||||
},
|
||||
time_series: generateTimeSeries(30, {
|
||||
avgCreated: 0,
|
||||
avgMerged: 0,
|
||||
avgClosed: 0,
|
||||
}),
|
||||
by_model: [],
|
||||
recent_prs: [],
|
||||
},
|
||||
timeRange: "30d",
|
||||
onTimeRangeChange: () => {},
|
||||
},
|
||||
};
|
||||
@@ -1,518 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { CodeIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "#/components/Chart/Chart";
|
||||
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatCostMicros } from "#/utils/currency";
|
||||
import { paginateItems } from "#/utils/paginateItems";
|
||||
import { PrStateIcon } from "./GitPanel/GitPanel";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PRInsightsTimeRange = "7d" | "14d" | "30d" | "90d";
|
||||
|
||||
interface PRInsightsViewProps {
|
||||
data: TypesGen.PRInsightsResponse;
|
||||
timeRange: PRInsightsTimeRange;
|
||||
onTimeRangeChange: (range: PRInsightsTimeRange) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatMergeRate(rate: number): string {
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const StatCard: FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
}> = ({ label, value, detail }) => (
|
||||
<div className="flex flex-col justify-between rounded-lg border border-border-default bg-surface-primary p-5">
|
||||
<p className="m-0 text-[13px] text-content-secondary">{label}</p>
|
||||
<div className="mt-2">
|
||||
<p className="m-0 text-[28px] font-semibold leading-none tracking-tight text-content-primary">
|
||||
{value}
|
||||
</p>
|
||||
<p className="m-0 mt-1.5 text-xs text-content-disabled">
|
||||
{detail ?? "\u00A0"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const prStateBadgeStyles: Record<string, string> = {
|
||||
merged: "text-git-merged-bright ring-current/20",
|
||||
closed: "text-git-deleted-bright ring-current/20",
|
||||
open: "text-git-added-bright ring-current/20",
|
||||
draft: "text-content-secondary ring-border-default",
|
||||
};
|
||||
|
||||
const prStateLabels: Record<string, string> = {
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
open: "Open",
|
||||
draft: "Draft",
|
||||
};
|
||||
|
||||
function prStateKey(state: string, draft: boolean): string {
|
||||
if (state === "merged" || state === "closed") return state;
|
||||
return draft ? "draft" : "open";
|
||||
}
|
||||
|
||||
const PRStateBadge: FC<{ state: string; draft: boolean }> = ({
|
||||
state,
|
||||
draft,
|
||||
}) => {
|
||||
const key = prStateKey(state, draft);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
|
||||
prStateBadgeStyles[key] ?? prStateBadgeStyles.open,
|
||||
)}
|
||||
>
|
||||
<PrStateIcon state={state} draft={draft} className="size-3" />
|
||||
{prStateLabels[key] ?? "Open"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-[6px] w-16 overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-git-merged-bright transition-all"
|
||||
style={{ width: `${Math.round(rate * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs tabular-nums text-content-primary">
|
||||
{formatMergeRate(rate)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activityChartConfig = {
|
||||
prs_created: {
|
||||
label: "Created",
|
||||
color: "hsl(var(--content-disabled))",
|
||||
},
|
||||
prs_merged: {
|
||||
label: "Merged",
|
||||
color: "hsl(var(--git-merged-bright))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatChartDate(dateStr: string): string {
|
||||
return dayjs(dateStr).format("MMM D");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity chart — simplified to created vs merged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ActivityChart: FC<{
|
||||
data: readonly TypesGen.PRInsightsTimeSeriesEntry[];
|
||||
}> = ({ data }) => (
|
||||
<ChartContainer config={activityChartConfig} className="aspect-auto h-full">
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[...data]}
|
||||
margin={{ top: 8, left: -20, right: 8, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillCreated" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_created)"
|
||||
stopOpacity={0.15}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_created)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMerged" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_merged)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_merged)"
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
tickMargin={12}
|
||||
minTickGap={40}
|
||||
tickFormatter={formatChartDate}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
tickFormatter={(v: number) => (v === 0 ? "" : String(v))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(v: string) => dayjs(v).format("ddd, MMM D")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_created"
|
||||
fill="url(#fillCreated)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_created)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_merged"
|
||||
fill="url(#fillMerged)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_merged)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EmptyState: FC = () => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<CodeIcon className="size-5 text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="m-0 text-sm font-medium text-content-primary">
|
||||
No pull requests yet
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-sm text-content-secondary">
|
||||
Pull request data will appear here once agents start shipping code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SectionTitle: FC<{ children: string }> = ({ children }) => (
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">{children}</h3>
|
||||
);
|
||||
|
||||
const timeRangeOptions: { value: PRInsightsTimeRange; label: string }[] = [
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "14d", label: "14d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
{ value: "90d", label: "90d" },
|
||||
];
|
||||
|
||||
const TimeRangeFilter: FC<{
|
||||
value: PRInsightsTimeRange;
|
||||
onChange: (range: PRInsightsTimeRange) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<div className="inline-flex -space-x-px">
|
||||
{timeRangeOptions.map((opt, i) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={value === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"min-w-0 rounded-none px-3",
|
||||
i === 0 && "rounded-l-md",
|
||||
i === timeRangeOptions.length - 1 && "rounded-r-md",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RECENT_PRS_PAGE_SIZE = 10;
|
||||
|
||||
export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
data,
|
||||
timeRange,
|
||||
onTimeRangeChange,
|
||||
}) => {
|
||||
const { summary, time_series, by_model, recent_prs } = data;
|
||||
const isEmpty = summary.total_prs_created === 0;
|
||||
|
||||
// Client-side pagination for recent PRs table.
|
||||
// Page resets to 1 on data refresh because the parent unmounts this
|
||||
// component during loading. Clamping ensures the page is valid if the
|
||||
// list shrinks without a full remount.
|
||||
const [recentPrsPage, setRecentPrsPage] = useState(1);
|
||||
const {
|
||||
pagedItems: pagedRecentPrs,
|
||||
clampedPage: clampedRecentPrsPage,
|
||||
hasPreviousPage: hasRecentPrsPrev,
|
||||
hasNextPage: hasRecentPrsNext,
|
||||
} = paginateItems(recent_prs, RECENT_PRS_PAGE_SIZE, recentPrsPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="m-0 text-xl font-semibold tracking-tight text-content-primary">
|
||||
Pull Request Insights
|
||||
</h2>
|
||||
<p className="m-0 mt-1 text-[13px] text-content-secondary">
|
||||
Code changes detected by Agents.
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangeFilter value={timeRange} onChange={onTimeRangeChange} />
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* ── Stat cards — 3 headline metrics ── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatCard
|
||||
label="Merged"
|
||||
value={summary.total_prs_merged.toLocaleString()}
|
||||
detail={`${summary.total_prs_created.toLocaleString()} created`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Merge rate"
|
||||
value={formatMergeRate(summary.merge_rate)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cost / merge"
|
||||
value={formatCostMicros(summary.cost_per_merged_pr_micros)}
|
||||
detail={`${formatCostMicros(summary.total_cost_micros)} total`}
|
||||
/>{" "}
|
||||
</div>
|
||||
|
||||
{/* ── Activity chart ── */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<SectionTitle>Activity</SectionTitle>
|
||||
<div className="flex items-center gap-5">
|
||||
{Object.entries(activityChartConfig).map(([key, cfg]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: cfg.color }}
|
||||
/>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[220px] rounded-lg border border-border-default p-4 pt-2">
|
||||
<ActivityChart data={time_series} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Model breakdown + Recent PRs ── */}
|
||||
<div className="space-y-6">
|
||||
{/* ── Model performance (simplified) ── */}
|
||||
{by_model.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>By model</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Model</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Merged
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3">Merge rate</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost / merge
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{by_model.map((m) => (
|
||||
<TableRow
|
||||
key={m.model_config_id}
|
||||
className="border-t border-border-default"
|
||||
>
|
||||
<TableCell className="px-4 py-3">
|
||||
<span className="font-medium text-content-primary">
|
||||
{m.display_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
<span>{m.merged_prs}</span>
|
||||
<span className="text-content-disabled">
|
||||
/{m.total_prs}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<InlineMergeBar rate={m.merge_rate} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.merged_prs > 0
|
||||
? formatCostMicros(m.cost_per_merged_pr_micros)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Recent pull requests (simplified) ── */}
|
||||
{recent_prs.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Pull requests</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="table-fixed text-sm">
|
||||
<colgroup>
|
||||
<col style={{ width: "auto" }} />
|
||||
<col style={{ width: 88 }} />
|
||||
<col style={{ width: 72 }} />
|
||||
<col style={{ width: 72 }} />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Title</TableHead>
|
||||
<TableHead className="px-4 py-3">Status</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Created
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>{" "}
|
||||
<TableBody>
|
||||
{pagedRecentPrs.map((pr) => (
|
||||
<TableRow
|
||||
key={pr.chat_id}
|
||||
className="border-t border-border-default transition-colors hover:bg-surface-secondary/50"
|
||||
>
|
||||
<TableCell className="overflow-hidden px-4 py-3">
|
||||
{" "}
|
||||
<a
|
||||
href={pr.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex min-w-0 items-start gap-1 text-sm font-medium text-content-primary no-underline hover:text-content-link"
|
||||
>
|
||||
<span className="block truncate">
|
||||
{pr.pr_title}
|
||||
</span>
|
||||
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-content-disabled opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</a>
|
||||
<div className="mt-1 flex items-center gap-1.5 truncate text-xs text-content-disabled">
|
||||
{" "}
|
||||
<img
|
||||
src={pr.author_avatar_url}
|
||||
alt=""
|
||||
className="size-3.5 rounded-full"
|
||||
/>
|
||||
<span>{pr.author_login}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">#{pr.pr_number}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<PRStateBadge state={pr.state} draft={pr.draft} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
|
||||
{formatCostMicros(pr.cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap px-4 py-3 text-right text-xs text-content-disabled">
|
||||
{dayjs(pr.created_at).format("MMM D")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{recent_prs.length > RECENT_PRS_PAGE_SIZE && (
|
||||
<div className="pt-4">
|
||||
<PaginationWidgetBase
|
||||
totalRecords={recent_prs.length}
|
||||
currentPage={clampedRecentPrsPage}
|
||||
pageSize={RECENT_PRS_PAGE_SIZE}
|
||||
onPageChange={setRecentPrsPage}
|
||||
hasPreviousPage={hasRecentPrsPrev}
|
||||
hasNextPage={hasRecentPrsNext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -400,9 +400,6 @@ const AgentSettingsMCPServersPage = lazy(
|
||||
const AgentSettingsSpendPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsSpendPage"),
|
||||
);
|
||||
const AgentSettingsInsightsPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsInsightsPage"),
|
||||
);
|
||||
const AgentSettingsTemplatesPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsTemplatesPage"),
|
||||
);
|
||||
@@ -823,7 +820,6 @@ export const router = createBrowserRouter(
|
||||
<Route path="spend" element={<AgentSettingsSpendPage />} />
|
||||
<Route path="limits" element={<Navigate to="spend" replace />} />
|
||||
<Route path="usage" element={<NavigateWithSearch to="spend" />} />
|
||||
<Route path="insights" element={<AgentSettingsInsightsPage />} />
|
||||
<Route path="templates" element={<AgentSettingsTemplatesPage />} />
|
||||
</Route>
|
||||
<Route path="analytics" element={<AgentAnalyticsPage />} />
|
||||
|
||||
Reference in New Issue
Block a user