mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: deduplicate PR insights, fix cost computation, simplify UI (#23251)
## Problem The `/agents/settings/insights` page had several issues: 1. **Duplicate PRs** in "Recent Pull Requests" — multiple chats referencing the same PR URL each produced a row 2. **Wildly wrong costs** — the cost subquery summed ALL messages across the entire chat *tree* (`GROUP BY root_chat_id`), so every chat in a tree got the same inflated total. When aggregated, the same tree cost was counted N× per PR in that tree 3. **UI clutter** — too many stat cards, too many table columns, mixed naming conventions ## Fix ### Backend (SQL) - **Deduplicate by PR URL** using `DISTINCT ON (COALESCE(cds.url, c.id::text))` across all 4 queries - **Fix cost computation**: use two CTEs — `pr_costs` sums cost from ALL chats that reference a PR (so review chats contribute), `deduped` picks one row per PR for state/additions/deletions via DISTINCT ON - **Tests**: 3 subtests covering multi-chat cost summing, different PRs no duplication, and duplicate URL counted once ### Frontend - **3 stat cards** (down from 5): Merged, Merge rate, Cost / merge - **2-line chart** (down from 3): created (dashed) + merged (solid) - **4-column model table** (down from 7): Model, Merged, Merge rate, Cost/merge - **4-column recent table** (down from 7): Title, Status, Cost, Created — with `table-fixed` to prevent overflow - **Consistent naming**: no mixed PR/PRs abbreviation, contextual labels since page title establishes context
This commit is contained in:
+1
-1
@@ -4330,7 +4330,7 @@ func (api *API) prInsights(rw http.ResponseWriter, r *http.Request) {
|
||||
modelEntries := make([]codersdk.PRInsightsModelBreakdown, 0, len(byModel))
|
||||
for _, m := range byModel {
|
||||
entry := codersdk.PRInsightsModelBreakdown{
|
||||
ModelConfigID: m.ModelConfigID,
|
||||
ModelConfigID: m.ModelConfigID.UUID,
|
||||
DisplayName: m.DisplayName,
|
||||
Provider: m.Provider,
|
||||
TotalPRs: m.TotalPrs,
|
||||
|
||||
@@ -341,16 +341,39 @@ type sqlcQuerier interface {
|
||||
// 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 individual PR rows with cost for the recent PRs table.
|
||||
// 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.
|
||||
GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, error)
|
||||
// PR Insights queries for the /agents analytics dashboard.
|
||||
// These aggregate data from chat_diff_statuses (PR metadata) joined
|
||||
// with chats and chat_messages (cost) to power the PR Insights view.
|
||||
//
|
||||
// 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)
|
||||
|
||||
@@ -9964,3 +9964,482 @@ func TestUpsertAISeats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
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, and model config ID.
|
||||
setupChatInfra := func(t *testing.T) (database.Store, uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
dbgen.Organization(t, store, database.Organization{})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
|
||||
_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "anthropic",
|
||||
DisplayName: "Anthropic",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mc, err := store.InsertChatModelConfig(ctx, 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
|
||||
}
|
||||
|
||||
createChat := func(t *testing.T, store database.Store, userID, mcID uuid.UUID, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
OwnerID: userID,
|
||||
LastModelConfigID: mcID,
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
chatA := createChat(t, store, userID, mcID, "chat-A")
|
||||
insertCostMessage(t, store, chatA.ID, userID, mcID, 5_000_000) // $5
|
||||
|
||||
chatB := createChat(t, store, userID, mcID, "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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
chatA := createChat(t, store, userID, mcID, "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, store, userID, mcID, "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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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, store database.Store, userID, mcID, parentID, rootID uuid.UUID, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
OwnerID: userID,
|
||||
LastModelConfigID: mcID,
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
prURL := "https://github.com/org/repo/pull/99"
|
||||
for i := 0; i < 3; i++ {
|
||||
chat := createChat(t, store, userID, mcID, 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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
})
|
||||
|
||||
t.Run("ChildChatCostsIncluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID := setupChatInfra(t)
|
||||
|
||||
// Parent chat with a $5 cost.
|
||||
parent := createChat(t, store, userID, mcID, "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, store, userID, mcID, parent.ID, parent.ID, "child-1")
|
||||
insertCostMessage(t, store, child1.ID, userID, mcID, 2_000_000)
|
||||
|
||||
child2 := createChildChat(t, store, userID, mcID, 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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
// Parent chat with $10 orchestration cost.
|
||||
parent := createChat(t, store, userID, mcID, "parent")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000)
|
||||
|
||||
// Child C1 ($5) creates PR1.
|
||||
c1 := createChildChat(t, store, userID, mcID, 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, store, userID, mcID, 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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
// Parent P ($10) creates PR1.
|
||||
parent := createChat(t, store, userID, mcID, "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, store, userID, mcID, 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, store, userID, mcID, 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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
// 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, store, userID, mcID, "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, store, userID, mcID, "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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
})
|
||||
|
||||
t.Run("ParentAndChildSameURL_DedupedWithCombinedCost", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID := setupChatInfra(t)
|
||||
|
||||
// Parent P ($10) links to a PR.
|
||||
parent := createChat(t, store, userID, mcID, "parent")
|
||||
insertCostMessage(t, store, parent.ID, userID, mcID, 10_000_000)
|
||||
|
||||
// Child C ($5) also links to the same PR URL.
|
||||
child := createChildChat(t, store, userID, mcID, 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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
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 := setupChatInfra(t)
|
||||
|
||||
// A chat linked to a PR but with NO chat_messages at all.
|
||||
// The PR should still appear with zero cost.
|
||||
chat := createChat(t, store, userID, mcID, "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.GetPRInsightsRecentPRs(context.Background(), database.GetPRInsightsRecentPRsParams{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
OwnerID: noOwner,
|
||||
LimitVal: 20,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
assert.Equal(t, int64(0), recent[0].CostMicros)
|
||||
})
|
||||
|
||||
t.Run("MergedCostMicros_OnlyCountsMerged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store, userID, mcID := setupChatInfra(t)
|
||||
|
||||
// Merged PR with $5 cost.
|
||||
chatMerged := createChat(t, store, userID, mcID, "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, store, userID, mcID, "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)
|
||||
})
|
||||
}
|
||||
|
||||
+254
-106
@@ -2415,33 +2415,68 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam
|
||||
}
|
||||
|
||||
const getPRInsightsPerModel = `-- name: GetPRInsightsPerModel :many
|
||||
SELECT
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
GROUP BY cmc.id, cmc.display_name, cmc.provider
|
||||
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.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(d.display_name, '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.provider
|
||||
ORDER BY total_prs DESC
|
||||
`
|
||||
|
||||
@@ -2452,18 +2487,22 @@ type GetPRInsightsPerModelParams struct {
|
||||
}
|
||||
|
||||
type GetPRInsightsPerModelRow struct {
|
||||
ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Provider string `db:"provider" json:"provider"`
|
||||
TotalPrs int64 `db:"total_prs" json:"total_prs"`
|
||||
MergedPrs int64 `db:"merged_prs" json:"merged_prs"`
|
||||
TotalAdditions int64 `db:"total_additions" json:"total_additions"`
|
||||
TotalDeletions int64 `db:"total_deletions" json:"total_deletions"`
|
||||
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
|
||||
MergedCostMicros int64 `db:"merged_cost_micros" json:"merged_cost_micros"`
|
||||
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 {
|
||||
@@ -2498,51 +2537,100 @@ func (q *sqlQuerier) GetPRInsightsPerModel(ctx context.Context, arg GetPRInsight
|
||||
}
|
||||
|
||||
const getPRInsightsRecentPRs = `-- name: GetPRInsightsRecentPRs :many
|
||||
SELECT
|
||||
c.id AS chat_id,
|
||||
cds.pull_request_title AS pr_title,
|
||||
cds.url AS pr_url,
|
||||
cds.pr_number,
|
||||
cds.pull_request_state AS state,
|
||||
cds.pull_request_draft AS draft,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cds.changed_files,
|
||||
cds.commits,
|
||||
cds.approved,
|
||||
cds.changes_requested,
|
||||
cds.reviewer_count,
|
||||
cds.author_login,
|
||||
cds.author_avatar_url,
|
||||
COALESCE(cds.base_branch, '')::text AS base_branch,
|
||||
COALESCE(cmc.display_name, cmc.model)::text AS model_display_name,
|
||||
COALESCE(cc.cost_micros, 0)::bigint AS cost_micros,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $4::int
|
||||
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 >= $2::timestamptz
|
||||
AND c.created_at < $3::timestamptz
|
||||
AND ($4::uuid IS NULL OR c.owner_id = $4::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(cmc.display_name, 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 >= $2::timestamptz
|
||||
AND c.created_at < $3::timestamptz
|
||||
AND ($4::uuid IS NULL OR c.owner_id = $4::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 $1::int
|
||||
`
|
||||
|
||||
type GetPRInsightsRecentPRsParams struct {
|
||||
LimitVal int32 `db:"limit_val" json:"limit_val"`
|
||||
StartDate time.Time `db:"start_date" json:"start_date"`
|
||||
EndDate time.Time `db:"end_date" json:"end_date"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
LimitVal int32 `db:"limit_val" json:"limit_val"`
|
||||
}
|
||||
|
||||
type GetPRInsightsRecentPRsRow struct {
|
||||
@@ -2568,12 +2656,15 @@ type GetPRInsightsRecentPRsRow struct {
|
||||
}
|
||||
|
||||
// Returns individual PR rows with cost for the recent PRs table.
|
||||
// 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.
|
||||
func (q *sqlQuerier) GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsightsRecentPRsParams) ([]GetPRInsightsRecentPRsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getPRInsightsRecentPRs,
|
||||
arg.LimitVal,
|
||||
arg.StartDate,
|
||||
arg.EndDate,
|
||||
arg.OwnerID,
|
||||
arg.LimitVal,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2618,29 +2709,63 @@ func (q *sqlQuerier) GetPRInsightsRecentPRs(ctx context.Context, arg GetPRInsigh
|
||||
|
||||
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 cds.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
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 {
|
||||
@@ -2662,8 +2787,22 @@ type GetPRInsightsSummaryRow struct {
|
||||
// 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
|
||||
@@ -2680,19 +2819,26 @@ func (q *sqlQuerier) GetPRInsightsSummary(ctx context.Context, arg GetPRInsights
|
||||
}
|
||||
|
||||
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', c.created_at)::timestamptz AS date,
|
||||
date_trunc('day', created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= $1::timestamptz
|
||||
AND c.created_at < $2::timestamptz
|
||||
AND ($3::uuid IS NULL OR c.owner_id = $3::uuid)
|
||||
GROUP BY date_trunc('day', c.created_at)
|
||||
ORDER BY date_trunc('day', c.created_at)
|
||||
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 {
|
||||
@@ -2709,6 +2855,8 @@ type GetPRInsightsTimeSeriesRow struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,118 +1,266 @@
|
||||
-- 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 cds.pull_request_state = 'merged')::bigint AS total_prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS total_prs_closed,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid);
|
||||
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', c.created_at)::timestamptz AS date,
|
||||
date_trunc('day', created_at)::timestamptz AS date,
|
||||
COUNT(*)::bigint AS prs_created,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS prs_merged,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'closed')::bigint AS prs_closed
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
GROUP BY date_trunc('day', c.created_at)
|
||||
ORDER BY date_trunc('day', c.created_at);
|
||||
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.
|
||||
SELECT
|
||||
cmc.id AS model_config_id,
|
||||
cmc.display_name,
|
||||
cmc.provider,
|
||||
COUNT(*)::bigint AS total_prs,
|
||||
COUNT(*) FILTER (WHERE cds.pull_request_state = 'merged')::bigint AS merged_prs,
|
||||
COALESCE(SUM(cds.additions), 0)::bigint AS total_additions,
|
||||
COALESCE(SUM(cds.deletions), 0)::bigint AS total_deletions,
|
||||
COALESCE(SUM(cc.cost_micros), 0)::bigint AS total_cost_micros,
|
||||
COALESCE(SUM(cc.cost_micros) FILTER (WHERE cds.pull_request_state = 'merged'), 0)::bigint AS merged_cost_micros
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
-- 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
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
GROUP BY cmc.id, cmc.display_name, cmc.provider
|
||||
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.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(d.display_name, '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.provider
|
||||
ORDER BY total_prs DESC;
|
||||
|
||||
-- name: GetPRInsightsRecentPRs :many
|
||||
-- Returns individual PR rows with cost for the recent PRs table.
|
||||
SELECT
|
||||
c.id AS chat_id,
|
||||
cds.pull_request_title AS pr_title,
|
||||
cds.url AS pr_url,
|
||||
cds.pr_number,
|
||||
cds.pull_request_state AS state,
|
||||
cds.pull_request_draft AS draft,
|
||||
cds.additions,
|
||||
cds.deletions,
|
||||
cds.changed_files,
|
||||
cds.commits,
|
||||
cds.approved,
|
||||
cds.changes_requested,
|
||||
cds.reviewer_count,
|
||||
cds.author_login,
|
||||
cds.author_avatar_url,
|
||||
COALESCE(cds.base_branch, '')::text AS base_branch,
|
||||
COALESCE(cmc.display_name, cmc.model)::text AS model_display_name,
|
||||
COALESCE(cc.cost_micros, 0)::bigint AS cost_micros,
|
||||
c.created_at
|
||||
FROM chat_diff_statuses cds
|
||||
JOIN chats c ON c.id = cds.chat_id
|
||||
JOIN chat_model_configs cmc ON cmc.id = c.last_model_config_id
|
||||
LEFT JOIN (
|
||||
-- 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.
|
||||
WITH pr_costs AS (
|
||||
SELECT
|
||||
COALESCE(ch.root_chat_id, ch.id) AS root_id,
|
||||
COALESCE(SUM(cm.total_cost_micros), 0) AS cost_micros
|
||||
FROM chat_messages cm
|
||||
JOIN chats ch ON ch.id = cm.chat_id
|
||||
WHERE cm.total_cost_micros IS NOT NULL
|
||||
GROUP BY COALESCE(ch.root_chat_id, ch.id)
|
||||
) cc ON cc.root_id = COALESCE(c.root_chat_id, c.id)
|
||||
WHERE cds.pull_request_state IS NOT NULL
|
||||
AND c.created_at >= @start_date::timestamptz
|
||||
AND c.created_at < @end_date::timestamptz
|
||||
AND (sqlc.narg('owner_id')::uuid IS NULL OR c.owner_id = sqlc.narg('owner_id')::uuid)
|
||||
ORDER BY c.created_at DESC
|
||||
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(cmc.display_name, 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 @limit_val::int;
|
||||
|
||||
@@ -16,20 +16,11 @@ import {
|
||||
} from "components/Table/Table";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import {
|
||||
ArrowDownRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleDotIcon,
|
||||
CodeIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageSquareTextIcon,
|
||||
} from "lucide-react";
|
||||
import { CodeIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { PrStateIcon } from "./GitPanel";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -50,81 +41,28 @@ interface PRInsightsViewProps {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pctChange(current: number, previous: number): number | null {
|
||||
if (previous === 0) return current > 0 ? 100 : null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return `${value >= 0 ? "+" : ""}${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function formatMergeRate(rate: number): string {
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
function formatLinesShipped(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TrendBadge: FC<{
|
||||
current: number;
|
||||
previous: number;
|
||||
invert?: boolean;
|
||||
}> = ({ current, previous, invert = false }) => {
|
||||
const change = pctChange(current, previous);
|
||||
if (change === null) return null;
|
||||
|
||||
const isPositive = invert ? change < 0 : change > 0;
|
||||
const isNegative = invert ? change > 0 : change < 0;
|
||||
|
||||
if (isPositive) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-green px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-success">
|
||||
<ArrowUpRightIcon className="size-3" />
|
||||
{formatPct(change)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isNegative) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-md bg-surface-red px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-destructive">
|
||||
<ArrowDownRightIcon className="size-3" />
|
||||
{formatPct(change)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[11px] font-medium leading-none text-content-secondary">
|
||||
0%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard: FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: React.ReactNode;
|
||||
detail?: string;
|
||||
}> = ({ label, value, trend, detail }) => (
|
||||
}> = ({ 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">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="m-0 text-[28px] font-semibold leading-none tracking-tight text-content-primary">
|
||||
{value}
|
||||
</p>
|
||||
{trend}
|
||||
</div>
|
||||
{detail && (
|
||||
<p className="m-0 mt-1.5 text-xs text-content-disabled">{detail}</p>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
@@ -169,7 +107,7 @@ const PRStateBadge: FC<{ state: string; draft: boolean }> = ({
|
||||
|
||||
const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-[6px] w-20 overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-[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)}%` }}
|
||||
@@ -188,16 +126,12 @@ const InlineMergeBar: FC<{ rate: number }> = ({ rate }) => (
|
||||
const activityChartConfig = {
|
||||
prs_created: {
|
||||
label: "Created",
|
||||
color: "hsl(var(--git-added-bright))",
|
||||
color: "hsl(var(--content-disabled))",
|
||||
},
|
||||
prs_merged: {
|
||||
label: "Merged",
|
||||
color: "hsl(var(--git-merged-bright))",
|
||||
},
|
||||
prs_closed: {
|
||||
label: "Closed",
|
||||
color: "hsl(var(--git-deleted-bright))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatChartDate(dateStr: string): string {
|
||||
@@ -205,7 +139,7 @@ function formatChartDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity chart
|
||||
// Activity chart — simplified to created vs merged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ActivityChart: FC<{
|
||||
@@ -215,14 +149,14 @@ const ActivityChart: FC<{
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[...data]}
|
||||
margin={{ top: 8, left: -8, right: 8, bottom: 0 }}
|
||||
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.35}
|
||||
stopOpacity={0.15}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
@@ -242,18 +176,6 @@ const ActivityChart: FC<{
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillClosed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-prs_closed)"
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-prs_closed)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
@@ -266,7 +188,8 @@ const ActivityChart: FC<{
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={12}
|
||||
tickMargin={4}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
tickFormatter={(v: number) => (v === 0 ? "" : String(v))}
|
||||
/>
|
||||
@@ -285,7 +208,8 @@ const ActivityChart: FC<{
|
||||
fill="url(#fillCreated)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_created)"
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
@@ -296,15 +220,6 @@ const ActivityChart: FC<{
|
||||
stroke="var(--color-prs_merged)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="prs_closed"
|
||||
fill="url(#fillClosed)"
|
||||
fillOpacity={1}
|
||||
stroke="var(--color-prs_closed)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
@@ -367,41 +282,6 @@ const TimeRangeFilter: FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReviewBadge: FC<{
|
||||
approved: boolean | undefined;
|
||||
changes_requested: boolean;
|
||||
reviewer_count: number | undefined;
|
||||
}> = ({ approved, changes_requested, reviewer_count }) => {
|
||||
if (!reviewer_count) {
|
||||
return <span className="text-xs text-content-disabled">No reviews</span>;
|
||||
}
|
||||
|
||||
if (approved === true && !changes_requested) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-success">
|
||||
<CheckCircle2Icon className="size-3.5" />
|
||||
{reviewer_count} approved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (changes_requested) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-content-warning">
|
||||
<MessageSquareTextIcon className="size-3.5" />
|
||||
Changes requested
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-content-secondary">
|
||||
<CircleDotIcon className="size-3.5" />
|
||||
{reviewer_count} reviewing
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main view
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -415,7 +295,7 @@ export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
const isEmpty = summary.total_prs_created === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-8">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
@@ -423,7 +303,7 @@ export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
Pull Request Insights
|
||||
</h2>
|
||||
<p className="m-0 mt-1 text-[13px] text-content-secondary">
|
||||
Code shipped by AI agents across your organization.
|
||||
Code changes detected by Agents.
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangeFilter value={timeRange} onChange={onTimeRangeChange} />
|
||||
@@ -433,54 +313,22 @@ export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* ── Stat cards ── */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
label="PRs created"
|
||||
value={summary.total_prs_created.toLocaleString()}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.total_prs_created}
|
||||
previous={summary.prev_total_prs_created}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* ── Stat cards — 3 headline metrics ── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatCard
|
||||
label="Merged"
|
||||
value={summary.total_prs_merged.toLocaleString()}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.total_prs_merged}
|
||||
previous={summary.prev_total_prs_merged}
|
||||
/>
|
||||
}
|
||||
detail={`${summary.total_prs_created.toLocaleString()} created`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Merge rate"
|
||||
value={formatMergeRate(summary.merge_rate)}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.merge_rate}
|
||||
previous={summary.prev_merge_rate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lines shipped"
|
||||
value={formatLinesShipped(summary.total_additions)}
|
||||
detail={`${formatLinesShipped(summary.total_deletions)} removed`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cost / merged PR"
|
||||
label="Cost / merge"
|
||||
value={formatCostMicros(summary.cost_per_merged_pr_micros)}
|
||||
trend={
|
||||
<TrendBadge
|
||||
current={summary.cost_per_merged_pr_micros}
|
||||
previous={summary.prev_cost_per_merged_pr_micros}
|
||||
invert
|
||||
/>
|
||||
}
|
||||
/>
|
||||
detail={`${formatCostMicros(summary.total_cost_micros)} total`}
|
||||
/>{" "}
|
||||
</div>
|
||||
|
||||
{/* ── Activity chart ── */}
|
||||
@@ -501,177 +349,140 @@ export const PRInsightsView: FC<PRInsightsViewProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[260px] rounded-lg border border-border-default p-4 pt-2">
|
||||
<div className="h-[220px] rounded-lg border border-border-default p-4 pt-2">
|
||||
<ActivityChart data={time_series} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Model performance ── */}
|
||||
{by_model.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Performance by model</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Model</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
PRs
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Merged
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3">Merge rate</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Changes
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Total cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost / merge
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{by_model.map((m) => (
|
||||
<TableRow
|
||||
key={m.model_config_id}
|
||||
className="border-t border-border-default"
|
||||
>
|
||||
<TableCell className="px-4 py-3">
|
||||
<span className="font-medium text-content-primary">
|
||||
{m.display_name}
|
||||
</span>
|
||||
<span className="ml-1.5 text-xs text-content-disabled">
|
||||
{m.provider}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.total_prs}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.merged_prs}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<InlineMergeBar rate={m.merge_rate} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DiffStatBadge
|
||||
additions={m.total_additions}
|
||||
deletions={m.total_deletions}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
|
||||
{formatCostMicros(m.total_cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-primary">
|
||||
{m.merged_prs > 0
|
||||
? formatCostMicros(m.cost_per_merged_pr_micros)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
{/* ── Model breakdown + Recent PRs side by side ── */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* ── 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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</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 ── */}
|
||||
{recent_prs.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Recent pull requests</SectionTitle>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs text-content-secondary [&>th]:font-normal">
|
||||
<TableHead className="px-4 py-3">Pull request</TableHead>
|
||||
<TableHead className="px-4 py-3">Status</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Changes
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Reviews
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3">Model</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Created
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recent_prs.map((pr) => (
|
||||
<TableRow
|
||||
key={pr.chat_id}
|
||||
className="border-t border-border-default transition-colors hover:bg-surface-secondary/50"
|
||||
>
|
||||
<TableCell className="max-w-[320px] px-4 py-3">
|
||||
<a
|
||||
href={pr.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1 text-sm font-medium text-content-primary no-underline hover:text-content-link"
|
||||
>
|
||||
<span className="truncate">{pr.pr_title}</span>
|
||||
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-content-disabled opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</a>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-content-disabled">
|
||||
<img
|
||||
src={pr.author_avatar_url}
|
||||
alt=""
|
||||
className="size-3.5 rounded-full"
|
||||
/>
|
||||
<span>{pr.author_login}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">#{pr.pr_number}</span>
|
||||
<span>→</span>
|
||||
<span className="font-mono">{pr.base_branch}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<PRStateBadge state={pr.state} draft={pr.draft} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DiffStatBadge
|
||||
additions={pr.additions}
|
||||
deletions={pr.deletions}
|
||||
/>
|
||||
<p className="m-0 mt-1 text-xs text-content-disabled">
|
||||
{pr.changed_files} file
|
||||
{pr.changed_files !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<ReviewBadge
|
||||
approved={pr.approved}
|
||||
changes_requested={pr.changes_requested}
|
||||
reviewer_count={pr.reviewer_count}
|
||||
/>
|
||||
</TableCell>{" "}
|
||||
<TableCell className="px-4 py-3 text-xs text-content-secondary">
|
||||
{pr.model_display_name}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums text-content-secondary">
|
||||
{formatCostMicros(pr.cost_micros)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap px-4 py-3 text-right text-xs text-content-disabled">
|
||||
{dayjs(pr.created_at).format("MMM D, h:mm A")}
|
||||
</TableCell>
|
||||
{/* ── Recent pull requests (simplified) ── */}
|
||||
{recent_prs.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<SectionTitle>Recent</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</TableHeader>{" "}
|
||||
<TableBody>
|
||||
{recent_prs.map((pr) => (
|
||||
<TableRow
|
||||
key={pr.chat_id}
|
||||
className="border-t border-border-default transition-colors hover:bg-surface-secondary/50"
|
||||
>
|
||||
<TableCell className="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>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user