Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
})
Expand Down
17 changes: 14 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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) } : {}),
})
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions src/sessions-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
Expand Down
72 changes: 72 additions & 0 deletions tests/sessions-by-pr.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})