From 014b95e18b444ca22d0d3382b91e23e4e7396f27 Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 20 Jul 2026 21:27:20 +0200 Subject: [PATCH] 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. --- src/main.ts | 41 +++++++++++++++++++- src/parser.ts | 17 +++++++-- src/sessions-report.ts | 63 +++++++++++++++++++++++++++++++ src/types.ts | 3 ++ tests/sessions-by-pr.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 tests/sessions-by-pr.test.ts diff --git a/src/main.ts b/src/main.ts index e2c1a722..4e059bca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2007,10 +2007,11 @@ program .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') .option('--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') }) diff --git a/src/parser.ts b/src/parser.ts index b0045ac8..a98e528d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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() + const sessionMap = new Map }>() 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() - 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) { diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 4484ec3d..4d70c2a9 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -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() + 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 } +} diff --git a/src/types.ts b/src/types.ts index 6052a07f..8b2686b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 toolBreakdown: Record mcpBreakdown: Record diff --git a/tests/sessions-by-pr.test.ts b/tests/sessions-by-pr.test.ts new file mode 100644 index 00000000..b94ac5bf --- /dev/null +++ b/tests/sessions-by-pr.test.ts @@ -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 }) + }) +})