mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 07:34:39 +00:00
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:
parent
00160275cd
commit
014b95e18b
5 changed files with 191 additions and 5 deletions
41
src/main.ts
41
src/main.ts
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
72
tests/sessions-by-pr.test.ts
Normal file
72
tests/sessions-by-pr.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue