sessions: spend per pull request via --by-pr

The rich session capture (#758) stores every PR URL a transcript
references, but nothing displayed it. codeburn sessions --by-pr now
groups spend by pull request: cost, savings, sessions, calls, and the
date span per PR, sorted by cost, with a GitHub-shortened label and
json output. Attribution is by reference, so a session that mentions
several PRs counts fully toward each row; the footer reports the
distinct-session total so overlapping rows are never mistaken for a
grand total. prLinks now ride from the cached file onto SessionSummary
in both the Claude pipeline and the generic provider path.
This commit is contained in:
reviewer 2026-07-20 21:27:20 +02:00
parent 00160275cd
commit 014b95e18b
5 changed files with 191 additions and 5 deletions

View file

@ -2007,10 +2007,11 @@ program
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--format <format>', 'Output format: table, json', 'table')
.option('--by-pr', 'Group spend by the pull requests each session referenced')
.action(async (opts) => {
assertProvider(opts.provider, 'sessions')
assertFormat(opts.format, ['table', 'json'], 'sessions')
const { aggregateSessions, renderJson, renderTable } = await import('./sessions-report.js')
const { aggregateSessions, aggregateByPr, prLinkedTotals, renderJson, renderTable } = await import('./sessions-report.js')
await loadPricing()
let range
@ -2025,7 +2026,43 @@ program
range = getDateRange(opts.period).range
}
const rows = aggregateSessions(await parseAllSessions(range, opts.provider))
const projects = await parseAllSessions(range, opts.provider)
if (opts.byPr) {
const prRows = aggregateByPr(projects)
if (opts.format === 'json') {
process.stdout.write(JSON.stringify({ prs: prRows, distinct: prLinkedTotals(projects) }, null, 2) + '\n')
return
}
if (prRows.length === 0) {
process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n')
return
}
const { cost, sessions } = prLinkedTotals(projects)
const { renderTable: renderTextTable } = await import('./text-table.js')
const table = renderTextTable(
[
{ header: 'PR' },
{ header: 'Cost', right: true },
{ header: 'Saved', right: true },
{ header: 'Sessions', right: true },
{ header: 'Calls', right: true },
{ header: 'First' },
{ header: 'Last' },
],
prRows.map(r => [
r.label,
`$${r.cost.toFixed(2)}`,
`$${r.savingsUSD.toFixed(2)}`,
String(r.sessions),
String(r.calls),
r.firstStarted.slice(0, 10),
r.lastEnded.slice(0, 10),
]),
)
process.stdout.write(table + `\nDistinct PR-linked spend: $${cost.toFixed(2)} across ${sessions} session${sessions === 1 ? '' : 's'}. A session referencing several PRs counts toward each, so rows exceed this total when links overlap.\n`)
return
}
const rows = aggregateSessions(projects)
const output = opts.format === 'json' ? renderJson(rows) : renderTable(rows)
process.stdout.write(output + '\n')
})

View file

@ -2046,6 +2046,7 @@ async function scanProjectDirs(
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
session.agentType = cachedFile.agentType
if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort()
if (session.apiCalls > 0) {
const projectKey = cachedFile.canonicalCwd
@ -2743,7 +2744,7 @@ async function parseProviderSources(
// Query-time: derive SessionSummary from all cached turns.
// Uses seenKeys (shared across providers) for cross-provider dedup.
const sessionMap = new Map<string, { project: string; projectPath?: string; turns: ClassifiedTurn[] }>()
const sessionMap = new Map<string, { project: string; projectPath?: string; turns: ClassifiedTurn[]; prLinks?: Set<string> }>()
for (const source of servedSources) {
const cachedFile = section.files[source.path]
@ -2772,8 +2773,17 @@ async function parseProviderSources(
if (!existing.projectPath && turn.calls[0]?.projectPath) {
existing.projectPath = turn.calls[0]!.projectPath
}
if (cachedFile.prLinks?.length) {
const links = (existing.prLinks ??= new Set())
for (const link of cachedFile.prLinks) links.add(link)
}
} else {
sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] })
sessionMap.set(key, {
project,
projectPath: turn.calls[0]?.projectPath,
turns: [classified],
...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}),
})
}
}
}
@ -2816,9 +2826,10 @@ async function parseProviderSources(
}
const projectMap = new Map<string, { projectPath?: string; sessions: SessionSummary[] }>()
for (const [key, { project, projectPath, turns }] of sessionMap) {
for (const [key, { project, projectPath, turns, prLinks }] of sessionMap) {
const sessionId = key.split(':')[1] ?? key
const session = buildSessionSummary(sessionId, project, turns)
if (prLinks?.size) session.prLinks = [...prLinks].sort()
if (session.apiCalls > 0) {
const existing = projectMap.get(project)
if (existing) {

View file

@ -79,3 +79,66 @@ export function renderTable(rows: SessionRow[]): string {
const format = (row: string[]) => row.map((value, i) => value.padEnd(widths[i]!)).join(' ').trimEnd()
return [format(headers), format(widths.map(width => '-'.repeat(width))), ...values.map(format)].join('\n')
}
export type PrRow = {
/// Full PR URL (the aggregation key).
url: string
/// Short display form, `owner/repo#123` for GitHub URLs, else the URL.
label: string
cost: number
savingsUSD: number
sessions: number
calls: number
firstStarted: string
lastEnded: string
}
const GITHUB_PR_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
export function shortenPrUrl(url: string): string {
const m = GITHUB_PR_RE.exec(url)
return m ? `${m[1]}/${m[2]}#${m[3]}` : url
}
/// Spend attributed to each pull request a session's transcript referenced.
/// A session that mentions two PRs counts fully toward BOTH rows: attribution
/// is by reference, not a split, so rows must never be summed into a grand
/// total (the caller reports distinct-session spend separately).
export function aggregateByPr(projects: ProjectSummary[]): PrRow[] {
const byUrl = new Map<string, PrRow>()
for (const project of projects) {
for (const session of project.sessions) {
if (!session.prLinks?.length) continue
for (const url of session.prLinks) {
const row = byUrl.get(url) ?? {
url, label: shortenPrUrl(url),
cost: 0, savingsUSD: 0, sessions: 0, calls: 0,
firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp,
}
row.cost += session.totalCostUSD
row.savingsUSD += session.totalSavingsUSD
row.sessions += 1
row.calls += session.apiCalls
if (session.firstTimestamp < row.firstStarted) row.firstStarted = session.firstTimestamp
if (session.lastTimestamp > row.lastEnded) row.lastEnded = session.lastTimestamp
byUrl.set(url, row)
}
}
}
return [...byUrl.values()].sort((a, b) => b.cost - a.cost)
}
/// Distinct-session totals across every PR-linked session, safe to present as
/// "spend that produced PRs" without the multi-link double count.
export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sessions: number } {
let cost = 0
let sessions = 0
for (const project of projects) {
for (const session of project.sessions) {
if (!session.prLinks?.length) continue
cost += session.totalCostUSD
sessions += 1
}
}
return { cost, sessions }
}

View file

@ -199,6 +199,9 @@ export type SessionSummary = {
totalCacheWriteTokens: number
apiCalls: number
turns: ClassifiedTurn[]
/// GitHub PR URLs captured from the session transcript (session-level,
/// deduplicated). Absent when none were observed.
prLinks?: string[]
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { aggregateByPr, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
function session(id: string, cost: number, calls: number, prLinks?: string[], first = '2026-07-01T10:00:00Z', last = '2026-07-01T11:00:00Z'): SessionSummary {
return {
sessionId: id, project: 'p',
firstTimestamp: first, lastTimestamp: last,
totalCostUSD: cost, totalSavingsUSD: 0, totalEstimatedCostUSD: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: calls, turns: [],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {} as SessionSummary['skillBreakdown'],
subagentBreakdown: {} as SessionSummary['subagentBreakdown'],
...(prLinks ? { prLinks } : {}),
}
}
function project(sessions: SessionSummary[]): ProjectSummary {
return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 }
}
describe('shortenPrUrl', () => {
it('shortens GitHub PR URLs and passes anything else through', () => {
expect(shortenPrUrl('https://github.com/getagentseal/codeburn/pull/755')).toBe('getagentseal/codeburn#755')
expect(shortenPrUrl('https://gitlab.com/x/y/-/merge_requests/3')).toBe('https://gitlab.com/x/y/-/merge_requests/3')
})
})
describe('aggregateByPr', () => {
it('groups sessions by PR, sorted by cost, tracking the date span', () => {
const rows = aggregateByPr([project([
session('a', 100, 40, ['https://github.com/o/r/pull/1'], '2026-07-01T10:00:00Z', '2026-07-01T11:00:00Z'),
session('b', 50, 10, ['https://github.com/o/r/pull/1'], '2026-06-20T09:00:00Z', '2026-06-20T10:00:00Z'),
session('c', 200, 80, ['https://github.com/o/r/pull/2']),
])])
expect(rows.map(r => r.label)).toEqual(['o/r#2', 'o/r#1'])
const pr1 = rows[1]!
expect(pr1.cost).toBe(150)
expect(pr1.sessions).toBe(2)
expect(pr1.calls).toBe(50)
expect(pr1.firstStarted).toBe('2026-06-20T09:00:00Z')
expect(pr1.lastEnded).toBe('2026-07-01T11:00:00Z')
})
it('a session referencing several PRs counts fully toward each row', () => {
const rows = aggregateByPr([project([
session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']),
])])
expect(rows).toHaveLength(2)
expect(rows[0]!.cost).toBe(100)
expect(rows[1]!.cost).toBe(100)
})
it('sessions without links contribute nothing', () => {
expect(aggregateByPr([project([session('a', 100, 40)])])).toEqual([])
})
})
describe('prLinkedTotals', () => {
it('counts each PR-linked session once regardless of how many PRs it references', () => {
const totals = prLinkedTotals([project([
session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']),
session('b', 50, 10, ['https://github.com/o/r/pull/1']),
session('c', 999, 1),
])])
expect(totals).toEqual({ cost: 150, sessions: 2 })
})
})