mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
fix(pr): push evidence beats the carried pr-link, show every PR fact
Claude Code's pr-link tags a session with its latest PR and CodeBurn carried that tag to every later turn and subagent run. A run or turn that pushes the head branch of a PR opened in the same data (paired gh pr create --head) now goes to that PR, same repo only; spend left only on the tag is carriedUSD. Rows also get runList (why each run counts), a locally evidenced state and follow-up round times, and a call opening several PRs pairs its pr-link with the last create. The PR card states every unknown and the expanded view shows timeline, runs, cost by phase and work as compact tables.
This commit is contained in:
parent
6ddd8b6735
commit
9c039782f2
11 changed files with 707 additions and 203 deletions
|
|
@ -41,15 +41,40 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': '{count} follow-up round',
|
||||
'pullRequests.facts.rounds.other': '{count} follow-up rounds',
|
||||
'pullRequests.facts.yourFollowUps': 'Your follow-ups: {count}',
|
||||
'pullRequests.facts.merged': 'Merged {duration} after open',
|
||||
'pullRequests.facts.prLinkOnly': 'Linked by pr-link only',
|
||||
'pullRequests.facts.prLinkOnlyTitle': 'Only Claude Code pr-link entries tie this spend to the PR. No command or prompt named it.',
|
||||
'pullRequests.split.title': 'By phase',
|
||||
'pullRequests.split.title': 'Cost by phase',
|
||||
'pullRequests.split.aria': '{label} spend by phase',
|
||||
'pullRequests.split.build': 'Building',
|
||||
'pullRequests.split.review': 'Review runs',
|
||||
'pullRequests.split.after': 'After first review',
|
||||
'pullRequests.reviews.you': 'Your review ({verdict})',
|
||||
'pullRequests.facts.status.open': 'Open',
|
||||
'pullRequests.facts.status.draft': 'Open · draft',
|
||||
'pullRequests.facts.status.closed': 'Closed',
|
||||
'pullRequests.facts.status.merged': 'Merged',
|
||||
'pullRequests.facts.mergedOn': 'Merged {date}',
|
||||
'pullRequests.facts.notTracked': 'Follow-ups: not tracked',
|
||||
'pullRequests.facts.noReview': 'No review run found',
|
||||
'pullRequests.facts.carried': '{amount} carried from session tag',
|
||||
'pullRequests.facts.carriedTitle': "Spend from turns and runs that only inherited this PR from the session's pr-link tag. None of their own commands, pushes or prompts named it.",
|
||||
'pullRequests.timeline.title': 'Timeline',
|
||||
'pullRequests.timeline.aria': '{label} timeline',
|
||||
'pullRequests.timeline.opened': 'Opened',
|
||||
'pullRequests.timeline.firstReview': 'First review',
|
||||
'pullRequests.timeline.round': 'Follow-up round {count}',
|
||||
'pullRequests.timeline.merged': 'Merged',
|
||||
'pullRequests.timeline.afterOpen': '{duration} after open',
|
||||
'pullRequests.timeline.none': 'No open, review or merge seen here.',
|
||||
'pullRequests.runs.title': 'Runs',
|
||||
'pullRequests.runs.aria': '{label} runs',
|
||||
'pullRequests.runs.mainSession': 'Main session',
|
||||
'pullRequests.runs.agentRun': 'Agent run',
|
||||
'pullRequests.runs.link.named': 'named this PR',
|
||||
'pullRequests.runs.link.pushed-head': "pushed this PR's branch",
|
||||
'pullRequests.runs.link.gh-pr': 'ran gh on this PR',
|
||||
'pullRequests.runs.link.opened': 'opened it',
|
||||
'pullRequests.runs.link.carried': 'carried from session tag',
|
||||
'pullRequests.work.other': 'Other',
|
||||
},
|
||||
fr: {
|
||||
'pullRequests.loading': 'Analyse des pull requests…',
|
||||
|
|
@ -91,15 +116,40 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': '{count} tour de suivi',
|
||||
'pullRequests.facts.rounds.other': '{count} tours de suivi',
|
||||
'pullRequests.facts.yourFollowUps': 'Vos suivis : {count}',
|
||||
'pullRequests.facts.merged': 'Fusionnée {duration} après ouverture',
|
||||
'pullRequests.facts.prLinkOnly': 'Liée uniquement par pr-link',
|
||||
'pullRequests.facts.prLinkOnlyTitle': 'Seules les entrées pr-link de Claude Code rattachent cette dépense à la PR. Aucune commande ni aucun prompt ne la nomme.',
|
||||
'pullRequests.split.title': 'Par phase',
|
||||
'pullRequests.split.title': 'Coût par phase',
|
||||
'pullRequests.split.aria': 'Dépense de {label} par phase',
|
||||
'pullRequests.split.build': 'Construction',
|
||||
'pullRequests.split.review': 'Exécutions de revue',
|
||||
'pullRequests.split.after': 'Après la première revue',
|
||||
'pullRequests.reviews.you': 'Votre revue ({verdict})',
|
||||
'pullRequests.facts.status.open': 'Ouverte',
|
||||
'pullRequests.facts.status.draft': 'Ouverte · brouillon',
|
||||
'pullRequests.facts.status.closed': 'Fermée',
|
||||
'pullRequests.facts.status.merged': 'Fusionnée',
|
||||
'pullRequests.facts.mergedOn': 'Fusionnée le {date}',
|
||||
'pullRequests.facts.notTracked': 'Suivis : non suivis',
|
||||
'pullRequests.facts.noReview': 'Aucune exécution de revue trouvée',
|
||||
'pullRequests.facts.carried': '{amount} hérités du tag de session',
|
||||
'pullRequests.facts.carriedTitle': "Dépense de tours et d'exécutions qui ont seulement hérité cette PR du tag pr-link de la session. Aucune de leurs commandes, aucun push ni prompt ne la nomme.",
|
||||
'pullRequests.timeline.title': 'Chronologie',
|
||||
'pullRequests.timeline.aria': 'Chronologie de {label}',
|
||||
'pullRequests.timeline.opened': 'Ouverte',
|
||||
'pullRequests.timeline.firstReview': 'Première revue',
|
||||
'pullRequests.timeline.round': 'Tour de suivi {count}',
|
||||
'pullRequests.timeline.merged': 'Fusionnée',
|
||||
'pullRequests.timeline.afterOpen': '{duration} après ouverture',
|
||||
'pullRequests.timeline.none': 'Aucune ouverture, revue ou fusion vue ici.',
|
||||
'pullRequests.runs.title': 'Exécutions',
|
||||
'pullRequests.runs.aria': 'Exécutions de {label}',
|
||||
'pullRequests.runs.mainSession': 'Session principale',
|
||||
'pullRequests.runs.agentRun': "Exécution d'agent",
|
||||
'pullRequests.runs.link.named': 'a nommé cette PR',
|
||||
'pullRequests.runs.link.pushed-head': 'a poussé la branche de cette PR',
|
||||
'pullRequests.runs.link.gh-pr': 'a lancé gh sur cette PR',
|
||||
'pullRequests.runs.link.opened': "l'a ouverte",
|
||||
'pullRequests.runs.link.carried': 'hérité du tag de session',
|
||||
'pullRequests.work.other': 'Autres',
|
||||
},
|
||||
ja: {
|
||||
'pullRequests.loading': 'プルリクエストを確認中…',
|
||||
|
|
@ -141,15 +191,40 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': 'フォローアップ {count} 回',
|
||||
'pullRequests.facts.rounds.other': 'フォローアップ {count} 回',
|
||||
'pullRequests.facts.yourFollowUps': 'あなたのフォローアップ: {count}',
|
||||
'pullRequests.facts.merged': 'オープンから {duration} 後にマージ',
|
||||
'pullRequests.facts.prLinkOnly': 'pr-link のみで関連付け',
|
||||
'pullRequests.facts.prLinkOnlyTitle': 'この支出を PR に結び付けているのは Claude Code の pr-link エントリだけです。PR を指したコマンドやプロンプトはありません。',
|
||||
'pullRequests.split.title': 'フェーズ別',
|
||||
'pullRequests.split.title': 'フェーズ別コスト',
|
||||
'pullRequests.split.aria': '{label} のフェーズ別支出',
|
||||
'pullRequests.split.build': '作成',
|
||||
'pullRequests.split.review': 'レビュー実行',
|
||||
'pullRequests.split.after': '最初のレビュー以降',
|
||||
'pullRequests.reviews.you': 'あなたのレビュー({verdict})',
|
||||
'pullRequests.facts.status.open': 'オープン',
|
||||
'pullRequests.facts.status.draft': 'オープン · ドラフト',
|
||||
'pullRequests.facts.status.closed': 'クローズ',
|
||||
'pullRequests.facts.status.merged': 'マージ済み',
|
||||
'pullRequests.facts.mergedOn': '{date} にマージ',
|
||||
'pullRequests.facts.notTracked': 'フォローアップ: 追跡なし',
|
||||
'pullRequests.facts.noReview': 'レビュー実行なし',
|
||||
'pullRequests.facts.carried': '{amount} はセッションタグからの引き継ぎ',
|
||||
'pullRequests.facts.carriedTitle': 'セッションの pr-link タグからこの PR を引き継いだだけのターンと実行の支出です。それ自身のコマンド、プッシュ、プロンプトはこの PR を指していません。',
|
||||
'pullRequests.timeline.title': 'タイムライン',
|
||||
'pullRequests.timeline.aria': '{label} のタイムライン',
|
||||
'pullRequests.timeline.opened': 'オープン',
|
||||
'pullRequests.timeline.firstReview': '最初のレビュー',
|
||||
'pullRequests.timeline.round': 'フォローアップ {count}',
|
||||
'pullRequests.timeline.merged': 'マージ',
|
||||
'pullRequests.timeline.afterOpen': 'オープンから {duration} 後',
|
||||
'pullRequests.timeline.none': 'オープン、レビュー、マージはここでは確認されていません。',
|
||||
'pullRequests.runs.title': '実行',
|
||||
'pullRequests.runs.aria': '{label} の実行',
|
||||
'pullRequests.runs.mainSession': 'メインセッション',
|
||||
'pullRequests.runs.agentRun': 'エージェント実行',
|
||||
'pullRequests.runs.link.named': 'この PR を指定',
|
||||
'pullRequests.runs.link.pushed-head': 'この PR のブランチにプッシュ',
|
||||
'pullRequests.runs.link.gh-pr': 'この PR に gh を実行',
|
||||
'pullRequests.runs.link.opened': 'この PR を作成',
|
||||
'pullRequests.runs.link.carried': 'セッションタグから引き継ぎ',
|
||||
'pullRequests.work.other': 'その他',
|
||||
},
|
||||
ko: {
|
||||
'pullRequests.loading': '풀 리퀘스트 확인 중…',
|
||||
|
|
@ -191,15 +266,40 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': '후속 라운드 {count}회',
|
||||
'pullRequests.facts.rounds.other': '후속 라운드 {count}회',
|
||||
'pullRequests.facts.yourFollowUps': '내 후속 작업: {count}',
|
||||
'pullRequests.facts.merged': '연 지 {duration} 후 병합',
|
||||
'pullRequests.facts.prLinkOnly': 'pr-link로만 연결됨',
|
||||
'pullRequests.facts.prLinkOnlyTitle': '이 지출을 PR에 연결하는 것은 Claude Code의 pr-link 항목뿐입니다. PR을 지정한 명령이나 프롬프트는 없습니다.',
|
||||
'pullRequests.split.title': '단계별',
|
||||
'pullRequests.split.title': '단계별 비용',
|
||||
'pullRequests.split.aria': '{label} 단계별 지출',
|
||||
'pullRequests.split.build': '구현',
|
||||
'pullRequests.split.review': '리뷰 실행',
|
||||
'pullRequests.split.after': '첫 리뷰 이후',
|
||||
'pullRequests.reviews.you': '내 리뷰 ({verdict})',
|
||||
'pullRequests.facts.status.open': '열림',
|
||||
'pullRequests.facts.status.draft': '열림 · 초안',
|
||||
'pullRequests.facts.status.closed': '닫힘',
|
||||
'pullRequests.facts.status.merged': '병합됨',
|
||||
'pullRequests.facts.mergedOn': '{date} 병합',
|
||||
'pullRequests.facts.notTracked': '후속 작업: 추적 안 됨',
|
||||
'pullRequests.facts.noReview': '리뷰 실행 없음',
|
||||
'pullRequests.facts.carried': '{amount} 세션 태그에서 이어짐',
|
||||
'pullRequests.facts.carriedTitle': '세션의 pr-link 태그에서 이 PR을 이어받기만 한 턴과 실행의 지출입니다. 그 자체의 명령, 푸시, 프롬프트는 이 PR을 지정하지 않았습니다.',
|
||||
'pullRequests.timeline.title': '타임라인',
|
||||
'pullRequests.timeline.aria': '{label} 타임라인',
|
||||
'pullRequests.timeline.opened': '열림',
|
||||
'pullRequests.timeline.firstReview': '첫 리뷰',
|
||||
'pullRequests.timeline.round': '후속 라운드 {count}',
|
||||
'pullRequests.timeline.merged': '병합',
|
||||
'pullRequests.timeline.afterOpen': '연 지 {duration} 후',
|
||||
'pullRequests.timeline.none': '여기서 확인된 열기, 리뷰, 병합이 없습니다.',
|
||||
'pullRequests.runs.title': '실행',
|
||||
'pullRequests.runs.aria': '{label} 실행',
|
||||
'pullRequests.runs.mainSession': '메인 세션',
|
||||
'pullRequests.runs.agentRun': '에이전트 실행',
|
||||
'pullRequests.runs.link.named': '이 PR을 지정함',
|
||||
'pullRequests.runs.link.pushed-head': '이 PR 브랜치에 푸시함',
|
||||
'pullRequests.runs.link.gh-pr': '이 PR에 gh 실행',
|
||||
'pullRequests.runs.link.opened': '이 PR을 염',
|
||||
'pullRequests.runs.link.carried': '세션 태그에서 이어짐',
|
||||
'pullRequests.work.other': '기타',
|
||||
},
|
||||
zhCN: {
|
||||
'pullRequests.loading': '正在扫描拉取请求…',
|
||||
|
|
@ -241,15 +341,40 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': '{count} 轮后续修改',
|
||||
'pullRequests.facts.rounds.other': '{count} 轮后续修改',
|
||||
'pullRequests.facts.yourFollowUps': '你的后续修改:{count}',
|
||||
'pullRequests.facts.merged': '创建后 {duration} 合并',
|
||||
'pullRequests.facts.prLinkOnly': '仅由 pr-link 关联',
|
||||
'pullRequests.facts.prLinkOnlyTitle': '只有 Claude Code 的 pr-link 记录把这笔支出关联到该 PR,没有命令或提示提到它。',
|
||||
'pullRequests.split.title': '按阶段',
|
||||
'pullRequests.split.title': '按阶段的成本',
|
||||
'pullRequests.split.aria': '{label} 按阶段的支出',
|
||||
'pullRequests.split.build': '开发',
|
||||
'pullRequests.split.review': '审查运行',
|
||||
'pullRequests.split.after': '首次审查之后',
|
||||
'pullRequests.reviews.you': '你的审查({verdict})',
|
||||
'pullRequests.facts.status.open': '打开',
|
||||
'pullRequests.facts.status.draft': '打开 · 草稿',
|
||||
'pullRequests.facts.status.closed': '已关闭',
|
||||
'pullRequests.facts.status.merged': '已合并',
|
||||
'pullRequests.facts.mergedOn': '{date} 合并',
|
||||
'pullRequests.facts.notTracked': '后续修改:未跟踪',
|
||||
'pullRequests.facts.noReview': '未发现审查运行',
|
||||
'pullRequests.facts.carried': '{amount} 沿用自会话标签',
|
||||
'pullRequests.facts.carriedTitle': '这部分支出来自只从会话 pr-link 标签沿用此 PR 的轮次和运行。它们自己的命令、推送和提示都没有提到它。',
|
||||
'pullRequests.timeline.title': '时间线',
|
||||
'pullRequests.timeline.aria': '{label} 时间线',
|
||||
'pullRequests.timeline.opened': '创建',
|
||||
'pullRequests.timeline.firstReview': '首次审查',
|
||||
'pullRequests.timeline.round': '第 {count} 轮后续修改',
|
||||
'pullRequests.timeline.merged': '合并',
|
||||
'pullRequests.timeline.afterOpen': '创建后 {duration}',
|
||||
'pullRequests.timeline.none': '这里未看到创建、审查或合并。',
|
||||
'pullRequests.runs.title': '运行',
|
||||
'pullRequests.runs.aria': '{label} 的运行',
|
||||
'pullRequests.runs.mainSession': '主会话',
|
||||
'pullRequests.runs.agentRun': '代理运行',
|
||||
'pullRequests.runs.link.named': '指名此 PR',
|
||||
'pullRequests.runs.link.pushed-head': '推送了此 PR 的分支',
|
||||
'pullRequests.runs.link.gh-pr': '对此 PR 运行了 gh',
|
||||
'pullRequests.runs.link.opened': '创建了它',
|
||||
'pullRequests.runs.link.carried': '沿用自会话标签',
|
||||
'pullRequests.work.other': '其他',
|
||||
},
|
||||
zhTW: {
|
||||
'pullRequests.loading': '正在掃描拉取請求…',
|
||||
|
|
@ -291,14 +416,39 @@ export const pullRequests: SectionCatalog = {
|
|||
'pullRequests.facts.rounds.one': '{count} 輪後續修改',
|
||||
'pullRequests.facts.rounds.other': '{count} 輪後續修改',
|
||||
'pullRequests.facts.yourFollowUps': '你的後續修改:{count}',
|
||||
'pullRequests.facts.merged': '建立後 {duration} 合併',
|
||||
'pullRequests.facts.prLinkOnly': '僅由 pr-link 關聯',
|
||||
'pullRequests.facts.prLinkOnlyTitle': '只有 Claude Code 的 pr-link 紀錄將這筆支出關聯到此 PR,沒有指令或提示提到它。',
|
||||
'pullRequests.split.title': '依階段',
|
||||
'pullRequests.split.title': '依階段的成本',
|
||||
'pullRequests.split.aria': '{label} 依階段的支出',
|
||||
'pullRequests.split.build': '開發',
|
||||
'pullRequests.split.review': '審查執行',
|
||||
'pullRequests.split.after': '首次審查之後',
|
||||
'pullRequests.reviews.you': '你的審查({verdict})',
|
||||
'pullRequests.facts.status.open': '開啟',
|
||||
'pullRequests.facts.status.draft': '開啟 · 草稿',
|
||||
'pullRequests.facts.status.closed': '已關閉',
|
||||
'pullRequests.facts.status.merged': '已合併',
|
||||
'pullRequests.facts.mergedOn': '{date} 合併',
|
||||
'pullRequests.facts.notTracked': '後續修改:未追蹤',
|
||||
'pullRequests.facts.noReview': '未發現審查執行',
|
||||
'pullRequests.facts.carried': '{amount} 沿用自工作階段標籤',
|
||||
'pullRequests.facts.carriedTitle': '這部分支出來自只從工作階段 pr-link 標籤沿用此 PR 的回合與執行。它們自己的指令、推送與提示都沒有提到它。',
|
||||
'pullRequests.timeline.title': '時間軸',
|
||||
'pullRequests.timeline.aria': '{label} 時間軸',
|
||||
'pullRequests.timeline.opened': '建立',
|
||||
'pullRequests.timeline.firstReview': '首次審查',
|
||||
'pullRequests.timeline.round': '第 {count} 輪後續修改',
|
||||
'pullRequests.timeline.merged': '合併',
|
||||
'pullRequests.timeline.afterOpen': '建立後 {duration}',
|
||||
'pullRequests.timeline.none': '這裡未看到建立、審查或合併。',
|
||||
'pullRequests.runs.title': '執行',
|
||||
'pullRequests.runs.aria': '{label} 的執行',
|
||||
'pullRequests.runs.mainSession': '主工作階段',
|
||||
'pullRequests.runs.agentRun': '代理執行',
|
||||
'pullRequests.runs.link.named': '指名此 PR',
|
||||
'pullRequests.runs.link.pushed-head': '推送了此 PR 的分支',
|
||||
'pullRequests.runs.link.gh-pr': '對此 PR 執行了 gh',
|
||||
'pullRequests.runs.link.opened': '建立了它',
|
||||
'pullRequests.runs.link.carried': '沿用自工作階段標籤',
|
||||
'pullRequests.work.other': '其他',
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,6 +371,10 @@ export type MenubarPayload = {
|
|||
mergedAt?: string | null
|
||||
timeToMergeMs?: number | null
|
||||
costSplit?: { buildUSD: number; reviewUSD: number; fixesUSD: number }
|
||||
carriedUSD?: number
|
||||
runList?: Array<{ kind: 'session' | 'subagent'; description: string | null; model: string | null; costUSD: number; at: string; link: 'named' | 'pushed-head' | 'gh-pr' | 'opened' | 'carried' }>
|
||||
state?: 'open' | 'draft' | 'merged' | 'closed' | null
|
||||
followUpAt?: string[]
|
||||
}>
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
|
|
|
|||
|
|
@ -372,75 +372,83 @@ describe('PullRequests', () => {
|
|||
})
|
||||
|
||||
describe('local PR facts', () => {
|
||||
const base = { savingsUSD: 0, sessions: 1, calls: 10, firstStarted: '2026-10-08T10:00:00Z', lastEnded: '2026-10-08T12:00:00Z', models: ['Opus 5.5'], categories: [{ name: 'Coding', cost: 1 }], linkEvidence: 'explicit' as const, openedAt: null, followUpPushes: 0, reviewCostUSD: 0, mergedAt: null, timeToMergeMs: null }
|
||||
const base = { savingsUSD: 0, sessions: 1, calls: 10, firstStarted: '2026-10-08T10:00:00Z', lastEnded: '2026-10-08T12:00:00Z', models: ['Opus 5.5'], categories: [{ name: 'Coding', cost: 1 }], linkEvidence: 'explicit' as const, openedAt: null, followUpPushes: 0, reviewCostUSD: 0, mergedAt: null, timeToMergeMs: null, carriedUSD: 0, runList: [], state: null, followUpAt: [] }
|
||||
const pr = (n: number) => ({ url: `https://github.com/o/r/pull/${n}`, label: `o/r#${n}` })
|
||||
const ROWS: PrPayload['rows'] = [
|
||||
{ ...base, ...pr(1), cost: 11.16, runs: 3, openedAt: '2026-10-08T10:00:00Z', followUpRounds: 0, oneShot: true, reviewRuns: [], mergedAt: '2026-10-08T10:11:00Z', timeToMergeMs: 660_000, costSplit: { buildUSD: 11.16, reviewUSD: 0, fixesUSD: 0 } },
|
||||
{ ...base, ...pr(2), cost: 14.11, runs: 4, followUpRounds: 1, followUpPushes: 1, oneShot: null, reviewRuns: [{ kind: 'agent', label: 'general-purpose', model: 'Opus 5.5', costUSD: 1.4, at: '2026-10-10T08:56:00Z', evidence: 'reads-pr' }], reviewCostUSD: 1.4, mergedAt: '2026-10-10T09:34:00Z', costSplit: { buildUSD: 0, reviewUSD: 1.4, fixesUSD: 12.71 } },
|
||||
{ ...base, ...pr(3), cost: 4.45, runs: 1, linkEvidence: 'pr-link-only', followUpRounds: null, oneShot: null, reviewRuns: [], costSplit: { buildUSD: 4.45, reviewUSD: 0, fixesUSD: 0 } },
|
||||
{ ...base, ...pr(1), cost: 11.16, runs: 3, openedAt: '2026-10-08T10:00:00Z', followUpRounds: 0, oneShot: true, reviewRuns: [], mergedAt: '2026-10-08T10:11:00Z', timeToMergeMs: 660_000, state: 'merged', costSplit: { buildUSD: 11.16, reviewUSD: 0, fixesUSD: 0 } },
|
||||
{ ...base, ...pr(2), cost: 14.11, runs: 4, followUpRounds: 1, followUpPushes: 1, oneShot: null, reviewRuns: [{ kind: 'agent', label: 'general-purpose', model: 'Opus 5.5', costUSD: 1.4, at: '2026-10-10T08:56:00Z', evidence: 'reads-pr' }], reviewCostUSD: 1.4, mergedAt: '2026-10-10T09:34:00Z', followUpAt: ['2026-10-10T09:15:00Z'], costSplit: { buildUSD: 0, reviewUSD: 1.4, fixesUSD: 12.71 } },
|
||||
{ ...base, ...pr(3), cost: 4.45, runs: 1, linkEvidence: 'pr-link-only', followUpRounds: null, oneShot: null, reviewRuns: [], carriedUSD: 4.45, costSplit: { buildUSD: 4.45, reviewUSD: 0, fixesUSD: 0 } },
|
||||
{ ...base, ...pr(4), cost: 9, runs: 6, openedAt: '2026-10-08T10:00:00Z', followUpRounds: 2, followUpPushes: 5, oneShot: false, reviewRuns: [
|
||||
{ kind: 'agent', label: 'general-purpose', model: 'Sonnet 5.5', costUSD: 0.5, at: '2026-10-08T10:30:00Z', evidence: 'reads-pr' },
|
||||
{ kind: 'you', label: 'approve', model: 'Opus 5.5', costUSD: 0.25, at: '2026-10-08T11:30:00Z', evidence: 'gh-pr-review' },
|
||||
], reviewCostUSD: 0.75, costSplit: { buildUSD: 6, reviewUSD: 0.75, fixesUSD: 2.25 } },
|
||||
// The #1732 shape: a draft opened here with its head a shell variable, no
|
||||
// review, most spend only carried by the session's pr-link tag.
|
||||
{ ...base, ...pr(5), cost: 30.62, runs: 9, openedAt: '2026-10-10T15:18:39Z', followUpRounds: null, oneShot: null, reviewRuns: [], state: 'draft', carriedUSD: 27.97,
|
||||
categories: ['Coding', 'Exploration', 'Debugging', 'Testing', 'Planning', 'Git ops', 'Docs'].map((name, i) => ({ name, cost: [19.6, 3.06, 2.5, 2, 1.5, 1, 0.96][i]! })),
|
||||
runList: [
|
||||
{ kind: 'session', description: null, model: 'Opus 5.5', costUSD: 0.62, at: '2026-10-10T15:17:17Z', link: 'opened' },
|
||||
{ kind: 'session', description: null, model: 'Opus 5.5', costUSD: 1.6, at: '2026-10-10T15:32:00Z', link: 'gh-pr' },
|
||||
{ kind: 'subagent', description: 'Create release/0.9.26 version branch', model: 'Sonnet 5.5', costUSD: 0.43, at: '2026-10-10T14:24:00Z', link: 'pushed-head' },
|
||||
{ kind: 'subagent', description: 'Research PR view: one-shot, reviews', model: 'Opus 5.5', costUSD: 27.97, at: '2026-10-10T16:30:48Z', link: 'carried' },
|
||||
],
|
||||
costSplit: { buildUSD: 30.62, reviewUSD: 0, fixesUSD: 0 } },
|
||||
{ ...base, ...pr(6), cost: 10, runs: 2, followUpRounds: null, oneShot: null, reviewRuns: [], state: 'closed', carriedUSD: 0.4, costSplit: { buildUSD: 10, reviewUSD: 0, fixesUSD: 0 } },
|
||||
]
|
||||
const payload = { rows: ROWS, distinctCost: 38.72, distinctSessions: 1, attributedCost: 38.72, unattributedCost: 0 }
|
||||
const payload = { rows: ROWS, distinctCost: 79.34, distinctSessions: 1, attributedCost: 79.34, unattributedCost: 0 }
|
||||
|
||||
async function card(n: number): Promise<HTMLElement> {
|
||||
return (await screen.findByRole('link', { name: `o/r#${n}` })).closest('article') as HTMLElement
|
||||
}
|
||||
async function opened(n: number): Promise<HTMLElement> {
|
||||
const el = await card(n)
|
||||
await userEvent.click(rowForLink(within(el).getByRole('link')))
|
||||
return el
|
||||
}
|
||||
const facts = (el: HTMLElement) => [...el.querySelectorAll('.pr-card-facts span')].map(s => s.textContent)
|
||||
|
||||
it('one shot, merge time and the run count', async () => {
|
||||
const tableRows = (region: HTMLElement) => [...region.querySelectorAll('tr')].map(r => [...r.querySelectorAll('td')].map(td => td.textContent))
|
||||
const render6 = () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
}
|
||||
|
||||
it('merged on its day, one shot, no review run found, and the run count', async () => {
|
||||
render6()
|
||||
const el = await card(1)
|
||||
expect(facts(el)).toEqual(['One shot', 'Merged 11m after open'])
|
||||
expect(facts(el)).toEqual([`Merged ${formatDayShort('2026-10-08T10:11:00Z')}`, 'One shot', 'No review run found'])
|
||||
expect(el).toHaveTextContent('3 runs')
|
||||
expect(el).not.toHaveTextContent('1 session')
|
||||
})
|
||||
|
||||
it('a PR opened elsewhere: your follow-ups, the review run, no merge time without an open', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
it('a PR opened elsewhere: merged, your follow-ups, the review run', async () => {
|
||||
render6()
|
||||
const el = await card(2)
|
||||
expect(facts(el)).toEqual(['Reviewed by Opus 5.5 run ($1.40)', 'Your follow-ups: 1'])
|
||||
expect(facts(el)).toEqual([`Merged ${formatDayShort('2026-10-10T09:34:00Z')}`, 'Your follow-ups: 1', 'Reviewed by Opus 5.5 run ($1.40)'])
|
||||
expect(el).not.toHaveTextContent('One shot')
|
||||
expect(el).not.toHaveTextContent('Merged')
|
||||
})
|
||||
|
||||
it('omits unknowns and says pr-link only, never stale or no review', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
it('the #24 shape: states every unknown, the carried spend and pr-link only', async () => {
|
||||
render6()
|
||||
const el = await card(3)
|
||||
expect(facts(el)).toEqual(['Linked by pr-link only'])
|
||||
expect(facts(el)).toEqual(['Follow-ups: not tracked', 'No review run found', '$4.45 carried from session tag', 'Linked by pr-link only'])
|
||||
expect(el).toHaveTextContent('1 run')
|
||||
expect(el.textContent).not.toMatch(/stale|review|follow-up|one shot/i)
|
||||
expect(el.textContent).not.toMatch(/stale|one shot/i)
|
||||
})
|
||||
|
||||
it('several review runs and counted rounds', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
expect(facts(await card(4))).toEqual(['2 review runs ($0.75)', '2 follow-up rounds'])
|
||||
render6()
|
||||
expect(facts(await card(4))).toEqual(['2 follow-up rounds', '2 review runs ($0.75)'])
|
||||
})
|
||||
|
||||
it('splits the spend by phase, summing to the cost, and lists the review runs', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
const el = await card(4)
|
||||
await userEvent.click(rowForLink(within(el).getByRole('link')))
|
||||
const region = within(el).getByRole('region', { name: 'o/r#4 spend by phase' })
|
||||
const rows = [...region.querySelectorAll('.pr-cat')].map(r => r.textContent)
|
||||
expect(rows).toEqual(['Building$6.00', 'Review runs$0.75', 'After first review$2.25', 'general-purposeSonnet 5.5$0.50', 'Your review (approve)Opus 5.5$0.25'])
|
||||
const split = ROWS[3]!.costSplit!
|
||||
expect(split.buildUSD + split.reviewUSD + split.fixesUSD).toBeCloseTo(ROWS[3]!.cost, 10)
|
||||
it('the #1732 shape: draft, follow-ups not tracked, no review, carried', async () => {
|
||||
render6()
|
||||
const el = await card(5)
|
||||
expect(facts(el)).toEqual(['Open · draft', 'Follow-ups: not tracked', 'No review run found', '$27.97 carried from session tag'])
|
||||
})
|
||||
|
||||
it('shows no phase split when no review run was seen', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(payload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
const el = await card(1)
|
||||
await userEvent.click(rowForLink(within(el).getByRole('link')))
|
||||
expect(within(el).queryByRole('region', { name: 'o/r#1 spend by phase' })).toBeNull()
|
||||
it('closed, and no carried note under the threshold', async () => {
|
||||
render6()
|
||||
expect(facts(await card(6))).toEqual(['Closed', 'Follow-ups: not tracked', 'No review run found'])
|
||||
})
|
||||
|
||||
it('an older payload without the fields keeps the session count and no facts line', async () => {
|
||||
|
|
@ -449,6 +457,49 @@ describe('PullRequests', () => {
|
|||
const el = (await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })).closest('article') as HTMLElement
|
||||
expect(el).toHaveTextContent('3 sessions')
|
||||
expect(el.querySelector('.pr-card-facts')).toBeNull()
|
||||
await userEvent.click(rowForLink(within(el).getByRole('link')))
|
||||
expect(within(el).queryByRole('region', { name: /timeline|runs|by phase/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to timeline, runs with their reasons, cost by phase, and a top-5 work table', async () => {
|
||||
render6()
|
||||
const el = await opened(5)
|
||||
const regions = [...el.querySelectorAll('[role="region"]')].map(r => r.getAttribute('aria-label'))
|
||||
expect(regions).toEqual(['o/r#5 timeline', 'o/r#5 runs', 'o/r#5 spend by phase', 'o/r#5 cost breakdown'])
|
||||
expect(tableRows(within(el).getByRole('region', { name: 'o/r#5 timeline' })).map(r => r[0])).toEqual(['Opened'])
|
||||
const runs = within(el).getByRole('region', { name: 'o/r#5 runs' })
|
||||
expect(tableRows(runs)).toEqual([
|
||||
['Main session', 'opened it', 'Opus 5.5', '$0.62'],
|
||||
['Main session', 'ran gh on this PR', 'Opus 5.5', '$1.60'],
|
||||
['Create release/0.9.26 version branch', "pushed this PR's branch", 'Sonnet 5.5', '$0.43'],
|
||||
['Research PR view: one-shot, reviews', 'carried from session tag', 'Opus 5.5', '$27.97'],
|
||||
])
|
||||
expect(runs).toHaveTextContent('$27.97 carried from session tag')
|
||||
expect(tableRows(within(el).getByRole('region', { name: 'o/r#5 spend by phase' }))).toEqual([
|
||||
['Building', '$30.62', '100%'], ['Review runs', '$0.00', '0%'], ['After first review', '$0.00', '0%'],
|
||||
])
|
||||
expect(tableRows(within(el).getByRole('region', { name: 'o/r#5 cost breakdown' }))).toEqual([
|
||||
['Coding', '$19.60', '64%'], ['Exploration', '$3.06', '10%'], ['Debugging', '$2.50', '8%'], ['Testing', '$2.00', '7%'], ['Planning', '$1.50', '5%'], ['Other', '$1.96', '6%'],
|
||||
])
|
||||
expect(el.querySelector('.pr-cat-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('puts follow-up rounds, the first review and the merge on the timeline, and splits by phase', async () => {
|
||||
render6()
|
||||
const el = await opened(2)
|
||||
expect(tableRows(within(el).getByRole('region', { name: 'o/r#2 timeline' })).map(r => r[0])).toEqual(['First review', 'Follow-up round 1', 'Merged'])
|
||||
const el4 = await opened(4)
|
||||
const split = ROWS[3]!.costSplit!
|
||||
expect(tableRows(within(el4).getByRole('region', { name: 'o/r#4 spend by phase' }))).toEqual([
|
||||
['Building', '$6.00', '67%'], ['Review runs', '$0.75', '8%'], ['After first review', '$2.25', '25%'],
|
||||
])
|
||||
expect(split.buildUSD + split.reviewUSD + split.fixesUSD).toBeCloseTo(ROWS[3]!.cost, 10)
|
||||
})
|
||||
|
||||
it('says when no open, review or merge was seen', async () => {
|
||||
render6()
|
||||
const el = await opened(3)
|
||||
expect(within(el).getByRole('region', { name: 'o/r#3 timeline' })).toHaveTextContent('No open, review or merge seen here.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { prFilters } from '../lib/investigation'
|
|||
import { rangeLabel } from '../components/TopBar'
|
||||
import type { InvestigateRequest } from './Overview'
|
||||
import { Icon } from '../components/icons'
|
||||
import { t } from '../i18n'
|
||||
import { localeTag, t } from '../i18n'
|
||||
|
||||
type PullRequests = NonNullable<MenubarPayload['current']['pullRequests']>
|
||||
type PrRow = PullRequests['rows'][number]
|
||||
|
|
@ -29,22 +29,65 @@ function spanLabel(firstStarted: string, lastEnded: string): string {
|
|||
return start === end ? start : `${start} - ${end}`
|
||||
}
|
||||
|
||||
// Facts read from local commands. An unknown is left out, never shown as a negative.
|
||||
// Facts read from local commands. Each one states its unknown instead of
|
||||
// disappearing; a field an older CLI does not send stays out.
|
||||
function prFacts(pr: PrRow): string[] {
|
||||
const facts: string[] = []
|
||||
const reviews = pr.reviewRuns ?? []
|
||||
const only = reviews.length === 1 ? reviews[0]! : null
|
||||
if (only?.kind === 'you') facts.push(t('pullRequests.facts.reviewedByYou', { amount: formatUsd(only.costUSD) }))
|
||||
else if (only?.model) facts.push(t('pullRequests.facts.reviewedBy', { model: only.model, amount: formatUsd(only.costUSD) }))
|
||||
else if (reviews.length) facts.push(t(`pullRequests.facts.reviewRuns.${reviews.length === 1 ? 'one' : 'other'}`, { count: reviews.length, amount: formatUsd(pr.reviewCostUSD ?? 0) }))
|
||||
if (pr.state === 'merged' || (!pr.state && pr.mergedAt)) facts.push(pr.mergedAt ? t('pullRequests.facts.mergedOn', { date: formatDayShort(pr.mergedAt) }) : t('pullRequests.facts.status.merged'))
|
||||
else if (pr.state) facts.push(t(`pullRequests.facts.status.${pr.state}`))
|
||||
const rounds = pr.followUpRounds ?? null
|
||||
if (pr.oneShot === true) facts.push(t('pullRequests.facts.oneShot'))
|
||||
else if (pr.oneShot === false && rounds !== null) facts.push(t(`pullRequests.facts.rounds.${rounds === 1 ? 'one' : 'other'}`, { count: rounds }))
|
||||
else if (rounds !== null) facts.push(t('pullRequests.facts.yourFollowUps', { count: rounds }))
|
||||
if (pr.timeToMergeMs != null) facts.push(t('pullRequests.facts.merged', { duration: formatDuration(pr.timeToMergeMs) }))
|
||||
else if (pr.oneShot !== undefined) facts.push(t('pullRequests.facts.notTracked'))
|
||||
const reviews = pr.reviewRuns
|
||||
const only = reviews?.length === 1 ? reviews[0]! : null
|
||||
if (only?.kind === 'you') facts.push(t('pullRequests.facts.reviewedByYou', { amount: formatUsd(only.costUSD) }))
|
||||
else if (only?.model) facts.push(t('pullRequests.facts.reviewedBy', { model: only.model, amount: formatUsd(only.costUSD) }))
|
||||
else if (reviews?.length) facts.push(t(`pullRequests.facts.reviewRuns.${reviews.length === 1 ? 'one' : 'other'}`, { count: reviews.length, amount: formatUsd(pr.reviewCostUSD ?? 0) }))
|
||||
else if (reviews) facts.push(t('pullRequests.facts.noReview'))
|
||||
return facts
|
||||
}
|
||||
|
||||
function carriedNote(pr: PrRow): string | null {
|
||||
const carried = pr.carriedUSD ?? 0
|
||||
return carried > 0 && carried >= Math.max(0.5, pr.cost * 0.2) ? t('pullRequests.facts.carried', { amount: formatUsd(carried) }) : null
|
||||
}
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(localeTag(), { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function timeline(pr: PrRow): Array<{ label: string; at: string; note?: string }> {
|
||||
const events: Array<{ label: string; at: string; note?: string }> = []
|
||||
if (pr.openedAt) events.push({ label: t('pullRequests.timeline.opened'), at: pr.openedAt })
|
||||
const firstReview = pr.reviewRuns?.[0]?.at
|
||||
if (firstReview) events.push({ label: t('pullRequests.timeline.firstReview'), at: firstReview })
|
||||
pr.followUpAt?.forEach((at, i) => events.push({ label: t('pullRequests.timeline.round', { count: i + 1 }), at }))
|
||||
if (pr.mergedAt) events.push({ label: t('pullRequests.timeline.merged'), at: pr.mergedAt, ...(pr.timeToMergeMs != null ? { note: t('pullRequests.timeline.afterOpen', { duration: formatDuration(pr.timeToMergeMs) }) } : {}) })
|
||||
return events.sort((a, b) => a.at.localeCompare(b.at))
|
||||
}
|
||||
|
||||
const share = (cost: number, total: number): string => `${total > 0 ? Math.round(cost / total * 100) : 0}%`
|
||||
|
||||
// Label, amount and share of the PR's cost, in the app's plain table style.
|
||||
function CostTable({ rows, total }: { rows: Array<{ key: string; label: string; cost: number }>; total: number }) {
|
||||
return (
|
||||
<table className="pr-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.key}>
|
||||
<td>{row.label}</td>
|
||||
<td>{formatUsd(row.cost)}</td>
|
||||
<td className="pr-share">{share(row.cost, total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelChips({ models }: { models: string[] }) {
|
||||
return (
|
||||
<div className="pr-model-list" aria-label={models.length ? t('pullRequests.models.usedAria', { models: models.join(', ') }) : t('pullRequests.models.noneAria')}>
|
||||
|
|
@ -233,17 +276,8 @@ function PrTable({ pullRequests, onInvestigate }: { pullRequests: PullRequests;
|
|||
function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expanded: boolean; onToggle: () => void; onInvestigate?: (request: InvestigateRequest) => void }) {
|
||||
const models = pr.models ?? []
|
||||
const categories = pr.categories ?? []
|
||||
const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0
|
||||
const facts = prFacts(pr)
|
||||
const reviews = pr.reviewRuns ?? []
|
||||
const split = pr.costSplit && reviews.length > 0
|
||||
? [
|
||||
{ key: 'build', cost: Math.max(0, pr.costSplit.buildUSD) },
|
||||
{ key: 'review', cost: pr.costSplit.reviewUSD },
|
||||
{ key: 'after', cost: pr.costSplit.fixesUSD },
|
||||
]
|
||||
: []
|
||||
const splitMax = Math.max(0, ...split.map(part => part.cost))
|
||||
const carried = carriedNote(pr)
|
||||
|
||||
return (
|
||||
<article className={expanded ? 'pr-card is-open' : 'pr-card'}>
|
||||
|
|
@ -266,9 +300,10 @@ function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expan
|
|||
<span>{pr.runs !== undefined ? formatCount(pr.runs, 'run') : formatCount(pr.sessions, 'session')}</span>
|
||||
<span>{formatCount(pr.calls, 'call')}</span>
|
||||
</div>
|
||||
{(facts.length > 0 || pr.linkEvidence === 'pr-link-only') && (
|
||||
{(facts.length > 0 || carried || pr.linkEvidence === 'pr-link-only') && (
|
||||
<div className="pr-card-meta pr-card-facts">
|
||||
{facts.map(fact => <span key={fact}>{fact}</span>)}
|
||||
{carried && <span title={t('pullRequests.facts.carriedTitle')}>{carried}</span>}
|
||||
{pr.linkEvidence === 'pr-link-only' && <span title={t('pullRequests.facts.prLinkOnlyTitle')}>{t('pullRequests.facts.prLinkOnly')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -284,77 +319,96 @@ function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expan
|
|||
</div>
|
||||
<span className="pr-chevron" aria-hidden="true"><Icon name="chevron-right" /></span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="pr-detail-cell">
|
||||
{/* Drill-through entry: a control of its own, never the row. The row
|
||||
is a toggle, so hanging the investigation off it would cost the
|
||||
expansion; this opens the sessions that composed the PR while the
|
||||
row stays exactly as the reader left it. The PR URL is the
|
||||
aggregation key of the by-PR report, so it selects at the
|
||||
destination without a lookup. */}
|
||||
{onInvestigate && (
|
||||
<button
|
||||
className="ov-link pr-drill"
|
||||
type="button"
|
||||
title={t('pullRequests.drill.viewSessionsTitle', { label: pr.label })}
|
||||
onClick={() => onInvestigate({ filters: prFilters(pr.url) })}
|
||||
>
|
||||
{t('pullRequests.drill.viewSessionsButton')}
|
||||
</button>
|
||||
)}
|
||||
{categories.length > 0 ? (
|
||||
<div className="pr-detail" role="region" aria-label={t('pullRequests.card.costBreakdownAria', { label: pr.label })}>
|
||||
<div className="pr-detail-head">
|
||||
<span>{t('pullRequests.card.workBreakdownTitle')}</span>
|
||||
<strong>{t('pullRequests.card.workBreakdownTotal', { amount: formatUsd(pr.cost) })}</strong>
|
||||
</div>
|
||||
<div className="pr-cats">
|
||||
{categories.map(cat => (
|
||||
<div className="pr-cat" key={cat.name}>
|
||||
<span className="pr-cat-name">{cat.name}</span>
|
||||
<div className="pr-cat-bar" aria-hidden="true">
|
||||
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
|
||||
</div>
|
||||
<strong>{formatUsd(cat.cost)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pr-cat-empty">{t('pullRequests.card.noPerTurnDetail')}</p>
|
||||
)}
|
||||
{split.length > 0 && (
|
||||
<div className="pr-detail pr-split" role="region" aria-label={t('pullRequests.split.aria', { label: pr.label })}>
|
||||
<div className="pr-detail-head">
|
||||
<span>{t('pullRequests.split.title')}</span>
|
||||
</div>
|
||||
<div className="pr-cats">
|
||||
{split.map(part => (
|
||||
<div className="pr-cat" key={part.key}>
|
||||
<span className="pr-cat-name">{t(`pullRequests.split.${part.key}`)}</span>
|
||||
<div className="pr-cat-bar" aria-hidden="true">
|
||||
<span style={{ width: `${splitMax > 0 ? part.cost / splitMax * 100 : 0}%` }} />
|
||||
</div>
|
||||
<strong>{formatUsd(part.cost)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pr-detail-head pr-reviews-head">
|
||||
<span>{t('pullRequests.split.review')}</span>
|
||||
</div>
|
||||
<div className="pr-cats">
|
||||
{reviews.map((run, i) => (
|
||||
<div className="pr-cat" key={i}>
|
||||
<span className="pr-cat-name">{run.kind === 'you' ? t('pullRequests.reviews.you', { verdict: run.label }) : run.label}</span>
|
||||
<span className="pr-cat-name">{run.model ?? ''}</span>
|
||||
<strong>{formatUsd(run.costUSD)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <PrDetail pr={pr} categories={categories} onInvestigate={onInvestigate} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PrDetail({ pr, categories, onInvestigate }: { pr: PrRow; categories: NonNullable<PrRow['categories']>; onInvestigate?: (request: InvestigateRequest) => void }) {
|
||||
const events = timeline(pr)
|
||||
const carried = carriedNote(pr)
|
||||
const runs = pr.runList ?? []
|
||||
const split = pr.costSplit
|
||||
const top = categories.slice(0, 5)
|
||||
const rest = categories.slice(5).reduce((sum, cat) => sum + cat.cost, 0)
|
||||
const work = [
|
||||
...top.map(cat => ({ key: cat.name, label: cat.name, cost: cat.cost })),
|
||||
...(rest > 0 ? [{ key: 'other', label: t('pullRequests.work.other'), cost: rest }] : []),
|
||||
]
|
||||
return (
|
||||
<div className="pr-detail-cell">
|
||||
<div className="pr-detail-grid">
|
||||
{pr.openedAt !== undefined && (
|
||||
<div className="pr-detail" role="region" aria-label={t('pullRequests.timeline.aria', { label: pr.label })}>
|
||||
<div className="pr-detail-head"><span>{t('pullRequests.timeline.title')}</span></div>
|
||||
{events.length > 0 ? (
|
||||
<table className="pr-table">
|
||||
<tbody>
|
||||
{events.map(event => (
|
||||
<tr key={`${event.label}-${event.at}`}>
|
||||
<td>{event.label}</td>
|
||||
<td>{timeLabel(event.at)}{event.note ? ` · ${event.note}` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <p className="pr-cat-empty">{t('pullRequests.timeline.none')}</p>}
|
||||
</div>
|
||||
)}
|
||||
{runs.length > 0 && (
|
||||
<div className="pr-detail" role="region" aria-label={t('pullRequests.runs.aria', { label: pr.label })}>
|
||||
<div className="pr-detail-head">
|
||||
<span>{t('pullRequests.runs.title')}</span>
|
||||
{carried && <strong>{carried}</strong>}
|
||||
</div>
|
||||
<table className="pr-table pr-runs">
|
||||
<tbody>
|
||||
{runs.map((run, i) => (
|
||||
<tr key={i}>
|
||||
<td title={run.description ?? undefined}>{run.description ?? t(run.kind === 'session' ? 'pullRequests.runs.mainSession' : 'pullRequests.runs.agentRun')}</td>
|
||||
<td className="pr-run-why">{t(`pullRequests.runs.link.${run.link}`)}</td>
|
||||
<td>{run.model ?? ''}</td>
|
||||
<td>{formatUsd(run.costUSD)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{split && (
|
||||
<div className="pr-detail" role="region" aria-label={t('pullRequests.split.aria', { label: pr.label })}>
|
||||
<div className="pr-detail-head"><span>{t('pullRequests.split.title')}</span></div>
|
||||
<CostTable total={pr.cost} rows={[
|
||||
{ key: 'build', label: t('pullRequests.split.build'), cost: Math.max(0, split.buildUSD) },
|
||||
{ key: 'review', label: t('pullRequests.split.review'), cost: split.reviewUSD },
|
||||
{ key: 'after', label: t('pullRequests.split.after'), cost: split.fixesUSD },
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
<div className="pr-detail" role="region" aria-label={t('pullRequests.card.costBreakdownAria', { label: pr.label })}>
|
||||
<div className="pr-detail-head">
|
||||
<span>{t('pullRequests.card.workBreakdownTitle')}</span>
|
||||
<strong>{t('pullRequests.card.workBreakdownTotal', { amount: formatUsd(pr.cost) })}</strong>
|
||||
</div>
|
||||
{work.length > 0 ? <CostTable rows={work} total={pr.cost} /> : <p className="pr-cat-empty">{t('pullRequests.card.noPerTurnDetail')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Drill-through entry: a control of its own, never the row. The row
|
||||
is a toggle, so hanging the investigation off it would cost the
|
||||
expansion; this opens the sessions that composed the PR while the
|
||||
row stays exactly as the reader left it. The PR URL is the
|
||||
aggregation key of the by-PR report, so it selects at the
|
||||
destination without a lookup. */}
|
||||
{onInvestigate && (
|
||||
<button
|
||||
className="ov-link pr-drill"
|
||||
type="button"
|
||||
title={t('pullRequests.drill.viewSessionsTitle', { label: pr.label })}
|
||||
onClick={() => onInvestigate({ filters: prFilters(pr.url) })}
|
||||
>
|
||||
{t('pullRequests.drill.viewSessionsButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -856,15 +856,14 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-medium); }
|
|||
.pr-detail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.pr-detail-head span { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); }
|
||||
.pr-detail-head strong { color: var(--mut); font-family: var(--mono); font-size: var(--fs-meta); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; }
|
||||
.pr-cats { display: flex; flex-direction: column; gap: 9px; }
|
||||
.pr-cat { display: grid; grid-template-columns: minmax(110px, 160px) minmax(120px, 1fr) 72px; align-items: center; gap: 12px; }
|
||||
.pr-cat-bar { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--fill) 82%, var(--mut2)); }
|
||||
.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--accent) 58%, white); }
|
||||
.pr-cat-name { overflow: hidden; color: var(--mut); font-size: var(--fs-body); font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); }
|
||||
.pr-split { margin-top: var(--sp-4); }
|
||||
.pr-reviews-head { margin-top: 14px; }
|
||||
.pr-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-4) var(--sp-5); margin-bottom: var(--sp-3); }
|
||||
.pr-detail-grid .pr-detail-head { margin-bottom: 6px; }
|
||||
.pr-table td { padding: 4px 8px; }
|
||||
.pr-table td:first-child { overflow: hidden; max-width: 0; width: 100%; font-size: var(--fs-meta); text-overflow: ellipsis; }
|
||||
.pr-table td.pr-share, .pr-table td.pr-run-why { color: var(--mut2); }
|
||||
.pr-table td.pr-run-why { font-family: var(--sans); }
|
||||
.pr-table tr:last-child td { border-bottom: 0; }
|
||||
.pr-footnote { margin: 14px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; }
|
||||
.pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 1040px) {
|
||||
|
|
@ -874,6 +873,7 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-medium); }
|
|||
}
|
||||
@media (max-width: 820px) {
|
||||
.pr-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.pr-detail-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.opt-waste { min-width: 0; }
|
||||
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export function redactProjectNames(payload: MenubarPayload, includeNames: boolea
|
|||
...row,
|
||||
url: prPseudonym(row.url),
|
||||
label: prPseudonym(row.url),
|
||||
// Run descriptions are free prompt text (branch names, URLs).
|
||||
runList: row.runList?.map(run => ({ ...run, description: null })),
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,17 +113,19 @@ export function reviewVerdict(args: string): 'approve' | 'request-changes' | 'co
|
|||
|
||||
export const FOLLOW_UP_GAP_MS = 10 * 60 * 1000
|
||||
|
||||
/// Pushes more than 10 minutes apart start a new follow-up round.
|
||||
export function rounds(pushMs: number[]): number {
|
||||
let n = 0
|
||||
/// Start of each follow-up round: pushes more than 10 minutes apart start a new one.
|
||||
export function roundStarts(pushMs: number[]): number[] {
|
||||
const out: number[] = []
|
||||
let last = -Infinity
|
||||
for (const ms of [...pushMs].sort((a, b) => a - b)) {
|
||||
if (ms - last > FOLLOW_UP_GAP_MS) n++
|
||||
if (ms - last > FOLLOW_UP_GAP_MS) out.push(ms)
|
||||
last = ms
|
||||
}
|
||||
return n
|
||||
return out
|
||||
}
|
||||
|
||||
export const rounds = (pushMs: number[]): number => roundStarts(pushMs).length
|
||||
|
||||
export function callCommands(call: { toolSequence?: Array<Array<{ command?: string }>> }): string[] {
|
||||
const out: string[] = []
|
||||
for (const step of call.toolSequence ?? []) for (const t of step) if (t.command) out.push(t.command)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { flatString } from './content-utils.js'
|
|||
import { ESTIMATED_COST_LEGEND, isEstimatedCost, markEstimated } from './format.js'
|
||||
import { modelRowKey } from './models.js'
|
||||
import { maxOf } from './math-utils.js'
|
||||
import { callCommands, createHead, ghPrCommands, isCommit, isPush, namedPr, namedPrNumber, prNumbersIn, pushes, readPrNumbers, readsCode, reviewVerdict, rounds, type NamedPr } from './pr-signals.js'
|
||||
import { callCommands, createHead, ghPrCommands, isCommit, namedPr, namedPrNumber, prNumbersIn, pushes, readPrNumbers, readsCode, reviewVerdict, roundStarts, type NamedPr } from './pr-signals.js'
|
||||
import { inferSessionProvider, sessionBillableOutputTokens } from './session-output.js'
|
||||
import { CATEGORY_LABELS } from './types.js'
|
||||
import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js'
|
||||
|
|
@ -473,6 +473,30 @@ export type PrRow = {
|
|||
/// `cost` split by phase, summing exactly to `cost`: review = reviewCostUSD,
|
||||
/// fixes = everything else from the first review on, build = the rest.
|
||||
costSplit: { buildUSD: number; reviewUSD: number; fixesUSD: number }
|
||||
/// Part of `cost` from turns and runs with no evidence of their own: only the
|
||||
/// session's carried pr-link tag (or a legacy even split) put them here.
|
||||
carriedUSD: number
|
||||
/// Every contribution to `cost`, by time: a session's turns grouped by why they
|
||||
/// count here, and each folded run. Costs sum to `cost`.
|
||||
runList: PrRun[]
|
||||
/// Latest state local commands show: a `--draft` open, then `gh pr
|
||||
/// merge/close/ready/reopen` naming this PR. null: none seen.
|
||||
state: 'open' | 'draft' | 'merged' | 'closed' | null
|
||||
/// Start of each follow-up round counted in `followUpRounds`.
|
||||
followUpAt: string[]
|
||||
}
|
||||
|
||||
/// Why a run counts toward a PR: its description named it, it pushed the PR's
|
||||
/// head branch, a command named the PR, it opened it, or only the carried tag.
|
||||
export type PrRunLink = 'named' | 'pushed-head' | 'gh-pr' | 'opened' | 'carried'
|
||||
|
||||
export type PrRun = {
|
||||
kind: 'session' | 'subagent'
|
||||
description: string | null
|
||||
model: string | null
|
||||
costUSD: number
|
||||
at: string
|
||||
link: PrRunLink
|
||||
}
|
||||
|
||||
export type PrReviewRun = {
|
||||
|
|
@ -587,6 +611,10 @@ export type ChildFold = {
|
|||
readsCode: boolean
|
||||
readPrs: Set<number>
|
||||
prNumbers: Set<number>
|
||||
/// Branches the subtree pushed (a bare push counts for the branch its session
|
||||
/// last pushed by name).
|
||||
branches: Set<string>
|
||||
description: string
|
||||
}
|
||||
|
||||
function parseMs(ts: string | undefined): number {
|
||||
|
|
@ -752,13 +780,19 @@ function buildChildFold(child: SessionSummary, index: Map<string, SessionSummary
|
|||
let reads = false
|
||||
const readPrs = new Set<number>()
|
||||
const prNumbers = new Set<number>()
|
||||
const branches = new Set<string>()
|
||||
let upstream: string | undefined
|
||||
for (const turn of child.turns) {
|
||||
let turnCost = 0
|
||||
for (const call of turn.assistantCalls) {
|
||||
turnCost += call.costUSD
|
||||
if (call.model) addToMap(models, call.model, call.costUSD)
|
||||
if (scanCommands) for (const cmd of callCommands(call)) {
|
||||
if (isPush(cmd)) pushed = true
|
||||
for (const named of pushes(cmd)) {
|
||||
pushed = true
|
||||
if (named.length) upstream = named.at(-1)
|
||||
for (const b of named.length ? named : upstream ? [upstream] : []) branches.add(b)
|
||||
}
|
||||
if (isCommit(cmd)) committed = true
|
||||
if (readsCode(cmd)) reads = true
|
||||
for (const n of readPrNumbers(cmd)) readPrs.add(n)
|
||||
|
|
@ -777,7 +811,7 @@ function buildChildFold(child: SessionSummary, index: Map<string, SessionSummary
|
|||
named: namedPr(description),
|
||||
...(child.agentType ? { agentType: child.agentType } : {}),
|
||||
mentioned: namedPrNumber(description),
|
||||
pushed, committed, readsCode: reads, readPrs, prNumbers,
|
||||
pushed, committed, readsCode: reads, readPrs, prNumbers, branches, description,
|
||||
}
|
||||
for (const gc of index.get(providerSessionKey(child)) ?? []) {
|
||||
// Skip a descendant whose id is ambiguous (two conflicting records share it):
|
||||
|
|
@ -791,6 +825,7 @@ function buildChildFold(child: SessionSummary, index: Map<string, SessionSummary
|
|||
fold.readsCode ||= gcf.readsCode
|
||||
for (const n of gcf.readPrs) fold.readPrs.add(n)
|
||||
for (const n of gcf.prNumbers) fold.prNumbers.add(n)
|
||||
for (const b of gcf.branches) fold.branches.add(b)
|
||||
for (const [m, c] of gcf.models) addToMap(fold.models, m, c)
|
||||
for (const [cat, c] of gcf.categories) addToMap(fold.categories, cat, c)
|
||||
if (gcf.firstTs && (!fold.firstTs || gcf.firstTs < fold.firstTs)) fold.firstTs = gcf.firstTs
|
||||
|
|
@ -937,14 +972,15 @@ export function resolveSubagentAttribution(projects: ProjectSummary[], scanComma
|
|||
/// touching several PRs); calls are split by largest-remainder so they stay whole.
|
||||
/// Each contribution also records the models of its calls and the turn's task
|
||||
/// category, both weighted by the same split share. Turns before the first
|
||||
/// reference land in `unattributed` (genuine session overhead).
|
||||
/// reference land in `unattributed` (genuine session overhead). `repoint` may
|
||||
/// swap one attributed turn's set (push evidence) without changing the carry.
|
||||
///
|
||||
/// Legacy fallback: a session whose transcript already expired keeps its
|
||||
/// session-level `prLinks` but has NO per-turn `prRefs`. With no turn boundaries
|
||||
/// to attribute by, split the whole session evenly across its prLinks, mark every
|
||||
/// portion `approx`, and carry the session's model union (its calls still name
|
||||
/// their models) but NO category breakdown, since none can be honestly assigned.
|
||||
export function attributeSessionPrSpend(session: AttributableSession): SessionPrAttribution {
|
||||
export function attributeSessionPrSpend(session: AttributableSession, repoint?: (turnIndex: number, carried: string[]) => string[]): SessionPrAttribution {
|
||||
const perUrl = new Map<string, PrContribution>()
|
||||
const unattributed = { cost: 0, calls: 0, savingsUSD: 0 }
|
||||
|
||||
|
|
@ -974,16 +1010,17 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr
|
|||
|
||||
let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null
|
||||
const turnPrs: Array<string[] | null> = []
|
||||
for (const turn of session.turns) {
|
||||
for (const [i, turn] of session.turns.entries()) {
|
||||
if (turn.prRefs?.length) current = turn.prRefs
|
||||
turnPrs.push(current)
|
||||
const prs = current && repoint ? repoint(i, current) : current
|
||||
turnPrs.push(prs)
|
||||
const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
|
||||
const calls = behavioralCallCount(turn.assistantCalls)
|
||||
const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0)
|
||||
// Cost/savings keep every call, so a supplementary-only turn (calls === 0)
|
||||
// still attributes its spend here — only a genuinely empty turn is skipped.
|
||||
if (cost === 0 && calls === 0 && savings === 0) continue
|
||||
if (current === null) {
|
||||
if (prs === null) {
|
||||
unattributed.cost += cost
|
||||
unattributed.calls += calls
|
||||
unattributed.savingsUSD += savings
|
||||
|
|
@ -993,11 +1030,11 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr
|
|||
for (const call of turn.assistantCalls) {
|
||||
if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD)
|
||||
}
|
||||
const share = 1 / current.length
|
||||
const callAlloc = allocateEven(calls, current.length)
|
||||
const share = 1 / prs.length
|
||||
const callAlloc = allocateEven(calls, prs.length)
|
||||
const startTs = turnStartTs(turn)
|
||||
const endTs = turn.assistantCalls.at(-1)?.timestamp || startTs
|
||||
current.forEach((url, i) => {
|
||||
prs.forEach((url, i) => {
|
||||
const e = ensureContribution(perUrl, url)
|
||||
if (startTs && (!e.firstTs || startTs < e.firstTs)) e.firstTs = startTs
|
||||
if (endTs > e.lastTs) e.lastTs = endTs
|
||||
|
|
@ -1041,6 +1078,29 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution {
|
|||
if (!s) signals.set(url, s = { spend: [], openedMs: NaN, explicit: false, reviews: [] })
|
||||
return s
|
||||
}
|
||||
// Opens first: a run can push branch B before B's PR is opened, possibly by
|
||||
// another session, so every head must be known before attributing.
|
||||
const linked = projects.flatMap(p => p.sessions.filter(s => s.prLinks?.length))
|
||||
const turnCmds = new Map<SessionSummary, TurnCommands[]>()
|
||||
for (const session of linked) {
|
||||
const tc = turnCommands(session)
|
||||
turnCmds.set(session, tc)
|
||||
matchSessionCreates(session, tc, sig)
|
||||
}
|
||||
// ponytail: a head branch reused by two PRs maps to neither.
|
||||
const heads = new Map<string, string | null>()
|
||||
for (const [url, s] of signals) if (s.head) heads.set(s.head, heads.has(s.head) && heads.get(s.head) !== url ? null : url)
|
||||
// Push evidence: the PRs (in the carried set's repo) whose head these branches are.
|
||||
const headPrs = (branches: Iterable<string>, carried: string[]): string[] | null => {
|
||||
if (!heads.size) return null
|
||||
const repos = new Set(carried.map(u => prParts(u)?.repo))
|
||||
const out = new Set<string>()
|
||||
for (const b of branches) {
|
||||
const u = heads.get(b)
|
||||
if (u && repos.has(prParts(u)?.repo)) out.add(u)
|
||||
}
|
||||
return out.size ? [...out] : null
|
||||
}
|
||||
let attributedCost = 0
|
||||
let unattributedCost = 0
|
||||
let sessions = 0
|
||||
|
|
@ -1092,7 +1152,8 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution {
|
|||
subagentSessions += rc.fold.foldedSessions
|
||||
if (!rc.prSet?.length) { unattributedCost += rc.fold.cost; continue }
|
||||
attributedCost += rc.fold.cost
|
||||
const prs = rc.prSet
|
||||
const namedOne = rc.fold.named !== null && rc.fold.named !== 'multi'
|
||||
const prs = (!namedOne && headPrs(rc.fold.branches, rc.prSet)) || rc.prSet
|
||||
const share = 1 / prs.length
|
||||
const callAlloc = allocateEven(rc.fold.calls, prs.length)
|
||||
prs.forEach((url, i) => {
|
||||
|
|
@ -1112,12 +1173,15 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution {
|
|||
if (!session.prLinks?.length) continue
|
||||
sessions += 1
|
||||
const sessionKey = rowSessionKey(session)
|
||||
const { perUrl, unattributed, turnPrs } = attributeSessionPrSpend(session)
|
||||
const tc = turnCmds.get(session)!
|
||||
const { perUrl, unattributed, turnPrs } = attributeSessionPrSpend(session, (i, carried) =>
|
||||
(tc[i]!.pushed.length && headPrs(tc[i]!.pushed, carried)) || carried)
|
||||
for (const [url, c] of perUrl) {
|
||||
attributedCost += c.cost
|
||||
addTo(url, sessionKey, c.firstTs || session.firstTimestamp, c.lastTs || session.lastTimestamp, c.cost, c.savingsUSD, c.calls, c.approx, c.models, c.categories)
|
||||
if (c.approx) sig(url).spend.push({ ms: parseMs(session.firstTimestamp), cost: c.cost, review: false, legacy: true, run: { kind: 'session', key: sessionKey, description: null, models: c.models, at: session.firstTimestamp, link: 'carried' } })
|
||||
}
|
||||
turnSignals(session, turnPrs, sig)
|
||||
turnSignals(session, sessionKey, tc, turnPrs, sig)
|
||||
unattributedCost += unattributed.cost
|
||||
foldChildren(session)
|
||||
}
|
||||
|
|
@ -1179,11 +1243,15 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution {
|
|||
return { rows, totals: { cost: attributedCost + unattributedCost, sessions, subagentSessions, attributedCost, unattributedCost } }
|
||||
}
|
||||
|
||||
type SpendRun = { kind: 'session' | 'subagent'; key: string; description: string | null; models: Map<string, number>; at: string; link: PrRunLink }
|
||||
|
||||
type PrSignals = {
|
||||
/// Each attributed share with its start time; `review` marks review spend.
|
||||
spend: Array<{ ms: number; cost: number; review: boolean }>
|
||||
/// Each attributed share with its start time and run; `review` marks review
|
||||
/// spend, `legacy` an even-split share with no turn data.
|
||||
spend: Array<{ ms: number; cost: number; review: boolean; legacy?: boolean; run: SpendRun }>
|
||||
openedMs: number
|
||||
head?: string
|
||||
draft?: boolean
|
||||
explicit: boolean
|
||||
reviews: PrReviewRun[]
|
||||
}
|
||||
|
|
@ -1213,7 +1281,11 @@ function foldSignals(s: PrSignals, url: string, fold: ChildFold, share: number,
|
|||
const pr = prParts(url)
|
||||
const readThis = !!pr && single && fold.named !== 'multi' && (fold.readPrs.has(pr.number) || (fold.mentioned === pr.number && fold.readsCode))
|
||||
const review = readThis && !fold.committed && !fold.pushed
|
||||
s.spend.push({ ms: parseMs(fold.firstTs), cost, review })
|
||||
const link: PrRunLink = pr && fold.mentioned === pr.number ? 'named'
|
||||
: s.head && fold.branches.has(s.head) ? 'pushed-head'
|
||||
: pr && fold.prNumbers.has(pr.number) ? 'gh-pr'
|
||||
: 'carried'
|
||||
s.spend.push({ ms: parseMs(fold.firstTs), cost, review, run: { kind: 'subagent', key: fold.agentId, description: fold.description || null, models: fold.models, at: fold.firstTs, link } })
|
||||
if (review) s.reviews.push({ kind: 'agent', label: fold.agentType ?? 'subagent', model: topModel(fold.models), costUSD: cost, at: fold.firstTs, evidence: 'reads-pr' })
|
||||
if (pr && (fold.prNumbers.has(pr.number) || fold.mentioned === pr.number)) s.explicit = true
|
||||
}
|
||||
|
|
@ -1221,51 +1293,69 @@ function foldSignals(s: PrSignals, url: string, fold: ChildFold, share: number,
|
|||
// Pair each `gh pr create` in a turn with a PR first referenced in that turn
|
||||
// (its pr-link lands there): per repo, creates in call order against the new PR
|
||||
// numbers ascending, since GitHub numbers grow. A retried create for the same
|
||||
// head keeps only the last one.
|
||||
// head keeps only the last one. One call opening several PRs gets a pr-link for
|
||||
// the last one only.
|
||||
function matchCreates(cmds: Array<{ cmd: string; ms: number }>, fresh: string[], sig: (url: string) => PrSignals): void {
|
||||
const creates = new Map<string, { ms: number; repo?: string; head?: string }>()
|
||||
const creates = new Map<string, { ms: number; repo?: string; head?: string; draft: boolean }>()
|
||||
cmds.forEach(c => ghPrCommands(c.cmd).forEach((g, j) => {
|
||||
if (g.verb !== 'create') return
|
||||
const head = createHead(g.args)
|
||||
creates.set(head ?? `${c.ms}:${j}`, { ms: c.ms, ...(g.repo ? { repo: g.repo } : {}), ...(head ? { head } : {}) })
|
||||
creates.set(head ?? `${c.ms}:${j}`, { ms: c.ms, ...(g.repo ? { repo: g.repo } : {}), ...(head ? { head } : {}), draft: /(?:^|\s)(?:--draft|-d)(?=\s|$)/.test(g.args) })
|
||||
}))
|
||||
if (!creates.size) return
|
||||
const byRepo = new Map<string, Array<{ ms: number; head?: string }>>()
|
||||
const byRepo = new Map<string, Array<{ ms: number; head?: string; draft: boolean }>>()
|
||||
for (const c of creates.values()) byRepo.set(c.repo ?? '', [...(byRepo.get(c.repo ?? '') ?? []), c])
|
||||
let unclaimed = [...fresh].sort((a, b) => (prParts(a)?.number ?? 0) - (prParts(b)?.number ?? 0))
|
||||
for (const repo of [...byRepo.keys()].sort().reverse()) {
|
||||
const cs = byRepo.get(repo)!.sort((a, b) => a.ms - b.ms)
|
||||
const us = unclaimed.filter(u => !repo || prParts(u)?.repo === repo)
|
||||
if (us.length !== cs.length) continue
|
||||
cs.forEach((c, k) => {
|
||||
const paired = us.length === cs.length ? cs : us.length === 1 ? cs.slice(-1) : null
|
||||
if (!paired) continue
|
||||
paired.forEach((c, k) => {
|
||||
const s = sig(us[k]!)
|
||||
if (Number.isNaN(s.openedMs) || c.ms < s.openedMs) { s.openedMs = c.ms; s.head = c.head }
|
||||
if (Number.isNaN(s.openedMs) || c.ms < s.openedMs) { s.openedMs = c.ms; s.head = c.head; s.draft = c.draft }
|
||||
s.explicit = true
|
||||
})
|
||||
unclaimed = unclaimed.filter(u => !us.includes(u))
|
||||
}
|
||||
}
|
||||
|
||||
// Walk a parent's turns with the PR set each was attributed to: opens, the
|
||||
// share of spend each PR carried, and `gh pr review <n>` posted for it.
|
||||
function turnSignals(session: SessionSummary, turnPrs: Array<string[] | null>, sig: (url: string) => PrSignals): void {
|
||||
const seen = new Set(session.prRefsAtRangeStart ?? [])
|
||||
session.turns.forEach((turn, i) => {
|
||||
type TurnCommands = { cmds: Array<{ cmd: string; ms: number }>; pushed: string[] }
|
||||
|
||||
// Each turn's commands, and the branches it pushed by name (a top-level session
|
||||
// moves between checkouts too freely to resolve a bare push).
|
||||
function turnCommands(session: SessionSummary): TurnCommands[] {
|
||||
return session.turns.map(turn => {
|
||||
const cmds = turn.assistantCalls.flatMap(call => {
|
||||
const list = callCommands(call)
|
||||
if (!list.length) return []
|
||||
const ms = parseMs(call.timestamp)
|
||||
return list.map(cmd => ({ cmd, ms }))
|
||||
})
|
||||
return { cmds, pushed: cmds.flatMap(c => pushes(c.cmd).flat()) }
|
||||
})
|
||||
}
|
||||
|
||||
function matchSessionCreates(session: SessionSummary, tc: TurnCommands[], sig: (url: string) => PrSignals): void {
|
||||
const seen = new Set(session.prRefsAtRangeStart ?? [])
|
||||
session.turns.forEach((turn, i) => {
|
||||
const fresh = (turn.prRefs ?? []).filter(u => !seen.has(u))
|
||||
for (const u of fresh) seen.add(u)
|
||||
if (fresh.length) matchCreates(cmds, fresh, sig)
|
||||
if (fresh.length) matchCreates(tc[i]!.cmds, fresh, sig)
|
||||
})
|
||||
}
|
||||
|
||||
// Walk a parent's turns with the PR set each was attributed to: the share of
|
||||
// spend each PR carried, why it counts there, and `gh pr review <n>` posted for it.
|
||||
function turnSignals(session: SessionSummary, sessionKey: string, tc: TurnCommands[], turnPrs: Array<string[] | null>, sig: (url: string) => PrSignals): void {
|
||||
session.turns.forEach((turn, i) => {
|
||||
const prs = turnPrs[i]
|
||||
if (!prs) return
|
||||
const cost = turn.assistantCalls.reduce((n, c) => n + c.costUSD, 0)
|
||||
if (cost === 0 && behavioralCallCount(turn.assistantCalls) === 0) return
|
||||
const share = cost / prs.length
|
||||
const at = turnStartTs(turn)
|
||||
const { cmds, pushed } = tc[i]!
|
||||
const gh = cmds.flatMap(c => ghPrCommands(c.cmd))
|
||||
const named = new Set(cmds.flatMap(c => [...prNumbersIn(c.cmd)]))
|
||||
const models = new Map<string, number>()
|
||||
|
|
@ -1274,23 +1364,32 @@ function turnSignals(session: SessionSummary, turnPrs: Array<string[] | null>, s
|
|||
const s = sig(url)
|
||||
const pr = prParts(url)
|
||||
const posted = pr ? gh.find(g => g.verb === 'review' && g.number === pr.number && (!g.repo || g.repo === pr.repo)) : undefined
|
||||
s.spend.push({ ms: parseMs(at), cost: share, review: !!posted })
|
||||
const mentioned = mentionsUrl(turn.userMessage, url)
|
||||
const namesPr = !!pr && named.has(pr.number)
|
||||
const link: PrRunLink = cmds.some(c => c.ms === s.openedMs) ? 'opened'
|
||||
: namesPr || posted ? 'gh-pr'
|
||||
: s.head && pushed.includes(s.head) ? 'pushed-head'
|
||||
: mentioned ? 'named'
|
||||
: 'carried'
|
||||
s.spend.push({ ms: parseMs(at), cost: share, review: !!posted, run: { kind: 'session', key: sessionKey, description: null, models, at, link } })
|
||||
if (posted) s.reviews.push({ kind: 'you', label: reviewVerdict(posted.args), model: topModel(models), costUSD: share, at, evidence: 'gh-pr-review' })
|
||||
if (!s.explicit && ((pr && named.has(pr.number)) || mentionsUrl(turn.userMessage, url))) s.explicit = true
|
||||
if (namesPr || mentioned) s.explicit = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type PrCommandScan = {
|
||||
pushes: Array<{ ms: number; branches: string[]; mentioned: number | null }>
|
||||
merges: Array<{ ms: number; number: number; repo?: string }>
|
||||
events: Array<{ ms: number; number: number; repo?: string; verb: 'merge' | 'close' | 'ready' | 'reopen' }>
|
||||
}
|
||||
|
||||
// Every push and merge in range, from every session. A bare `git push` in a
|
||||
// subagent run pushes the branch it last pushed by name; a top-level session
|
||||
// moves between checkouts too freely to assume that.
|
||||
const STATE_EVENTS = new Set(['merge', 'close', 'ready', 'reopen'])
|
||||
|
||||
// Every push and state change (merge, close, ready, reopen) in range, from every
|
||||
// session. A bare `git push` in a subagent run pushes the branch it last pushed
|
||||
// by name; a top-level session moves between checkouts too freely to assume that.
|
||||
function scanPrCommands(projects: ProjectSummary[]): PrCommandScan {
|
||||
const out: PrCommandScan = { pushes: [], merges: [] }
|
||||
const out: PrCommandScan = { pushes: [], events: [] }
|
||||
for (const project of projects) for (const session of project.sessions) {
|
||||
const side = !!session.isSidechain
|
||||
let mentioned: number | null | undefined
|
||||
|
|
@ -1307,7 +1406,7 @@ function scanPrCommands(projects: ProjectSummary[]): PrCommandScan {
|
|||
out.pushes.push({ ms, branches: branches.length ? branches : upstream ? [upstream] : [], mentioned: mentioned ?? null })
|
||||
}
|
||||
for (const g of ghPrCommands(cmd)) {
|
||||
if (g.verb === 'merge' && g.number !== undefined) out.merges.push({ ms, number: g.number, ...(g.repo ? { repo: g.repo } : {}) })
|
||||
if (STATE_EVENTS.has(g.verb) && g.number !== undefined) out.events.push({ ms, number: g.number, ...(g.repo ? { repo: g.repo } : {}), verb: g.verb as PrCommandScan['events'][number]['verb'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1315,11 +1414,35 @@ function scanPrCommands(projects: ProjectSummary[]): PrCommandScan {
|
|||
return out
|
||||
}
|
||||
|
||||
// One runList entry per folded run, and per session and link reason.
|
||||
function runList(spend: PrSignals['spend']): PrRun[] {
|
||||
const out: Array<PrRun & { models: Map<string, number> }> = []
|
||||
const bySession = new Map<string, PrRun & { models: Map<string, number> }>()
|
||||
for (const x of spend) {
|
||||
const { key, models, ...run } = x.run
|
||||
const k = `${key}${KEY_SEP}${run.link}`
|
||||
const hit = run.kind === 'session' ? bySession.get(k) : undefined
|
||||
if (hit) {
|
||||
hit.costUSD += x.cost
|
||||
if (run.at && (!hit.at || run.at < hit.at)) hit.at = run.at
|
||||
for (const [m, c] of models) addToMap(hit.models, m, c)
|
||||
continue
|
||||
}
|
||||
const entry = { ...run, model: null, costUSD: x.cost, models: new Map(models) }
|
||||
if (run.kind === 'session') bySession.set(k, entry)
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
.sort((a, b) => a.at.localeCompare(b.at))
|
||||
.map(({ models, ...run }) => ({ ...run, model: topModel(models) }))
|
||||
}
|
||||
|
||||
// ponytail: a bare PR number (a merge, or a run's description) is matched
|
||||
// against this report's rows only; two rows with that number in different repos
|
||||
// leave it unmatched.
|
||||
function rowSignals(url: string, s: PrSignals | undefined, cost: number, scan: PrCommandScan, byNumber: Map<number, string[]>): Pick<PrRow,
|
||||
'linkEvidence' | 'openedAt' | 'followUpRounds' | 'followUpPushes' | 'oneShot' | 'reviewRuns' | 'reviewCostUSD' | 'mergedAt' | 'timeToMergeMs' | 'costSplit'> {
|
||||
'linkEvidence' | 'openedAt' | 'followUpRounds' | 'followUpPushes' | 'oneShot' | 'reviewRuns' | 'reviewCostUSD' | 'mergedAt' | 'timeToMergeMs' | 'costSplit'
|
||||
| 'carriedUSD' | 'runList' | 'state' | 'followUpAt'> {
|
||||
const pr = prParts(url)
|
||||
const isRow = (number: number, repo?: string): boolean => {
|
||||
const hits = (byNumber.get(number) ?? []).filter(u => !repo || prParts(u)!.repo === repo)
|
||||
|
|
@ -1333,15 +1456,26 @@ function rowSignals(url: string, s: PrSignals | undefined, cost: number, scan: P
|
|||
.filter(p => (s?.head && p.branches.includes(s.head)) || (pr && p.mentioned === pr.number && isRow(pr.number)))
|
||||
.map(p => p.ms)
|
||||
.filter(ms => opened === null || ms > opened)
|
||||
const followUpRounds = tracked || pushMs.length ? rounds(pushMs) : null
|
||||
const merged = Math.min(Infinity, ...scan.merges.filter(m => isRow(m.number, m.repo) && (opened === null || m.ms >= opened)).map(m => m.ms))
|
||||
const starts = tracked || pushMs.length ? roundStarts(pushMs) : null
|
||||
const followUpRounds = starts ? starts.length : null
|
||||
const events = scan.events.filter(e => isRow(e.number, e.repo) && (opened === null || e.ms >= opened))
|
||||
const merged = Math.min(Infinity, ...events.filter(e => e.verb === 'merge').map(e => e.ms))
|
||||
const mergedMs = Number.isFinite(merged) ? merged : null
|
||||
const reviewSpend = (s?.spend ?? []).filter(x => x.review)
|
||||
// A plain open says nothing about now (a merge by script is not seen), so
|
||||
// only a draft open counts; a reopened draft stays a draft until `gh pr ready`.
|
||||
let draft = opened !== null && !!s?.draft
|
||||
let state: PrRow['state'] = draft ? 'draft' : null
|
||||
for (const e of [...events].sort((a, b) => a.ms - b.ms)) {
|
||||
if (e.verb === 'ready') draft = false
|
||||
state = e.verb === 'merge' ? 'merged' : e.verb === 'close' ? 'closed' : draft ? 'draft' : 'open'
|
||||
}
|
||||
const spend = s?.spend ?? []
|
||||
const reviewSpend = spend.filter(x => x.review)
|
||||
const firstReview = Math.min(...reviewSpend.map(x => x.ms))
|
||||
const reviewUSD = reviewSpend.reduce((n, x) => n + x.cost, 0)
|
||||
const fixesUSD = (s?.spend ?? []).filter(x => !x.review && x.ms >= firstReview).reduce((n, x) => n + x.cost, 0)
|
||||
const fixesUSD = spend.filter(x => !x.review && !x.legacy && x.ms >= firstReview).reduce((n, x) => n + x.cost, 0)
|
||||
return {
|
||||
linkEvidence: !s?.spend.length ? null : s.explicit || mergedMs !== null ? 'explicit' : 'pr-link-only',
|
||||
linkEvidence: !spend.some(x => !x.legacy) ? null : s!.explicit || mergedMs !== null ? 'explicit' : 'pr-link-only',
|
||||
openedAt: opened === null ? null : new Date(opened).toISOString(),
|
||||
followUpRounds,
|
||||
followUpPushes: pushMs.length,
|
||||
|
|
@ -1351,6 +1485,10 @@ function rowSignals(url: string, s: PrSignals | undefined, cost: number, scan: P
|
|||
mergedAt: mergedMs === null ? null : new Date(mergedMs).toISOString(),
|
||||
timeToMergeMs: opened !== null && mergedMs !== null && mergedMs > opened ? mergedMs - opened : null,
|
||||
costSplit: { buildUSD: cost - reviewUSD - fixesUSD, reviewUSD, fixesUSD },
|
||||
carriedUSD: spend.filter(x => x.run.link === 'carried').reduce((n, x) => n + x.cost, 0),
|
||||
runList: runList(spend),
|
||||
state,
|
||||
followUpAt: (starts ?? []).map(ms => new Date(ms).toISOString()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ import { DAILY_CACHE_VERSION } from './daily-cache.js'
|
|||
// v11: Copilot OTel trace-level tool/skill/bash metadata is now attributed
|
||||
// once per trace, so older snapshots can carry per-span-duplicated skill/tool
|
||||
// turns and cost for otherwise identical period queries.
|
||||
export const STATUS_SNAPSHOT_RENDER_VERSION = 11
|
||||
// v12: pullRequests rows carry carriedUSD, runList, state and followUpAt, and
|
||||
// push evidence moves spend between rows; an older snapshot shows the old card.
|
||||
export const STATUS_SNAPSHOT_RENDER_VERSION = 12
|
||||
|
||||
/// The semantic key recorded on every status snapshot. A snapshot whose stored
|
||||
/// key differs (an older render revision, or a different daily-cache version)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ function payload(): MenubarPayload {
|
|||
cost: 4, savingsUSD: 0, sessions: 1, calls: 8,
|
||||
firstStarted: '2026-06-01T10:00:00.000Z', lastEnded: '2026-06-01T12:00:00.000Z',
|
||||
approx: false, models: ['Opus'],
|
||||
runList: [{ kind: 'subagent', description: 'Fix secret-client/repo#42 on feat/secret', model: 'Opus', costUSD: 4, at: '2026-06-01T10:00:00.000Z', link: 'named' }],
|
||||
}],
|
||||
distinctCost: 4, distinctSessions: 1, attributedCost: 4, unattributedCost: 0,
|
||||
},
|
||||
|
|
@ -119,6 +120,7 @@ describe('redact', () => {
|
|||
expect(row.calls).toBe(8)
|
||||
expect(JSON.stringify(out)).not.toContain('github.com/secret-client/repo')
|
||||
expect(JSON.stringify(out)).not.toContain('secret-client/repo#42')
|
||||
expect(row.runList).toEqual([{ kind: 'subagent', description: null, model: 'Opus', costUSD: 4, at: '2026-06-01T10:00:00.000Z', link: 'named' }])
|
||||
})
|
||||
it('gives the same PR the same pseudonym across calls, distinct from other PRs', () => {
|
||||
const first = redactProjectNames(payload(), false).current.pullRequests!.rows[0]!.url
|
||||
|
|
|
|||
|
|
@ -256,3 +256,102 @@ describe('session why: PR work', () => {
|
|||
expect(why.prWork.reviewCost).toBeCloseTo(['agent-aR1694', 'agent-aR1712', 'agent-aR1729'].reduce((n, id) => n + sessionCost(id), 0), 12)
|
||||
})
|
||||
})
|
||||
|
||||
// The 10 Oct 2026 #1732 case: one call opens two PRs (pr-link only for the last,
|
||||
// a draft), then later runs carry that tag while they build and push another
|
||||
// branch whose PR is opened afterwards with a literal --head.
|
||||
describe('push evidence beats the carried pr-link', () => {
|
||||
const S2 = '33333333-3333-4333-8333-333333333333'
|
||||
const l2 = (o: Record<string, unknown>) => JSON.stringify({ sessionId: S2, cwd: CWD, ...o }) + '\n'
|
||||
const u2 = (ts: string, content: unknown, extra: Record<string, unknown> = {}) => l2({ type: 'user', timestamp: ts, message: { role: 'user', content }, ...extra })
|
||||
const a2 = (ts: string, tools: Tool[], model = 'claude-opus-4-8', extra: Record<string, unknown> = {}) => l2({
|
||||
type: 'assistant', timestamp: ts, ...extra,
|
||||
message: { id: `m${msg++}`, type: 'message', role: 'assistant', model, content: tools.map((t, i) => ({ type: 'tool_use', id: t.id ?? `tu${msg}_${i}`, name: t.name, input: t.input })), usage: { input_tokens: 1000, output_tokens: 500 } },
|
||||
})
|
||||
const link2 = (ts: string, n: number) => l2({ type: 'pr-link', timestamp: ts, prUrl: PR(n) })
|
||||
const result2 = (ts: string, id: string, agentId: string) => u2(ts, [{ type: 'tool_result', tool_use_id: id, content: 'done' }], { toolUseResult: { status: 'completed', agentId, content: 'done' } })
|
||||
const runs2: Array<{ agentId: string; spawnId: string; description: string; at: string; tools: Array<[string, Tool[]]> }> = [
|
||||
{ agentId: 'aResearch', spawnId: 'toolu_research', description: 'Research PR view: one-shot, reviews', at: '2026-10-10T16:30:48.000Z',
|
||||
tools: [['2026-10-10T16:35:00.000Z', [bash('grep -c pr-link ~/.claude/projects/x.jsonl')]]] },
|
||||
{ agentId: 'aBuild', spawnId: 'toolu_build', description: 'Build desktop PR card', at: '2026-10-10T17:36:09.000Z',
|
||||
tools: [
|
||||
['2026-10-10T17:40:00.000Z', [bash('git checkout -q -b feat/pr-insights-ui origin/main')]],
|
||||
['2026-10-10T18:11:48.000Z', [bash('git commit -qam "feat(pr): card" && git push -q -u origin feat/pr-insights-ui 2>&1 | tail -1')]],
|
||||
['2026-10-10T18:16:24.000Z', [bash('git push -q 2>&1 | tail -1')]],
|
||||
] },
|
||||
]
|
||||
|
||||
beforeEach(async () => {
|
||||
const projDir = join(configDir, 'projects', 'prins-1732')
|
||||
const subDir = join(projDir, S2, 'subagents')
|
||||
await mkdir(subDir, { recursive: true })
|
||||
await writeFile(join(projDir, `${S2}.jsonl`), [
|
||||
u2('2026-10-10T15:17:00.000Z', 'open the ci fix and the release bump'),
|
||||
a2('2026-10-10T15:18:42.000Z', [bash('cd $C && git push -q origin HEAD:release/0.9.26 && gh pr create -R getagentseal/codeburn --base main --head fix/ci-p12-rewrap --title "ci(mac): cert" --body-file /tmp/cifix.md 2>&1 | tail -1 && gh pr create -R getagentseal/codeburn --base main --head release/0.9.26 --draft --title "chore: bump to 0.9.26" --body-file /tmp/rel.md 2>&1 | tail -1')]),
|
||||
link2('2026-10-10T15:18:48.000Z', 1732),
|
||||
u2('2026-10-10T16:30:00.000Z', 'research the PR view'),
|
||||
a2('2026-10-10T16:30:46.000Z', [spawn('toolu_research', runs2[0]!.description)]),
|
||||
result2('2026-10-10T16:47:29.000Z', 'toolu_research', 'aResearch'),
|
||||
link2('2026-10-10T16:47:30.000Z', 1732),
|
||||
u2('2026-10-10T17:35:00.000Z', 'build the card'),
|
||||
a2('2026-10-10T17:35:48.000Z', [spawn('toolu_build', runs2[1]!.description)]),
|
||||
result2('2026-10-10T18:17:40.000Z', 'toolu_build', 'aBuild'),
|
||||
link2('2026-10-10T18:17:41.000Z', 1732),
|
||||
u2('2026-10-10T19:22:00.000Z', 'open the PR'),
|
||||
a2('2026-10-10T19:22:39.000Z', [bash('gh pr create -R getagentseal/codeburn --base main --head feat/pr-insights-ui --title "feat(pr): card" --body-file /tmp/pri.md')]),
|
||||
link2('2026-10-10T19:22:41.000Z', 1733),
|
||||
u2('2026-10-10T19:41:00.000Z', 'rebase the release branch'),
|
||||
a2('2026-10-10T19:41:52.000Z', [bash('cd $C && git push -q --force-with-lease origin release/0.9.26 && gh pr reopen 1732 -R getagentseal/codeburn')]),
|
||||
// Still tagged #1733 here: the push to release/0.9.26 re-points this turn only.
|
||||
u2('2026-10-10T20:29:00.000Z', 'what next'),
|
||||
a2('2026-10-10T20:29:10.000Z', [bash('ls')]),
|
||||
link2('2026-10-10T20:29:11.000Z', 1732),
|
||||
].join(''))
|
||||
for (const r of runs2) {
|
||||
const side = { isSidechain: true, agentId: r.agentId }
|
||||
await writeFile(join(subDir, `agent-${r.agentId}.jsonl`), [
|
||||
u2(r.at, 'do the task', side),
|
||||
...r.tools.map(([ts, tools]) => a2(ts, tools, 'claude-opus-4-8', side)),
|
||||
].join(''))
|
||||
await writeFile(join(subDir, `agent-${r.agentId}.meta.json`), JSON.stringify({ agentType: 'general-purpose', description: r.description, toolUseId: r.spawnId }))
|
||||
}
|
||||
clearSessionCache()
|
||||
projects = await parseAllSessions({ start: new Date('2026-10-10T00:00:00Z'), end: new Date('2026-10-10T23:59:59Z') }, 'claude')
|
||||
})
|
||||
|
||||
const s2Turns = (from: string, to: string) => projects.flatMap(p => p.sessions).find(s => s.sessionId === S2)!
|
||||
.turns.filter(t => t.timestamp >= from && t.timestamp < to).reduce((s, t) => s + t.assistantCalls.reduce((n, c) => n + c.costUSD, 0), 0)
|
||||
|
||||
it('moves the run that pushed the new PR head there and keeps the research run on the tag, marked carried', () => {
|
||||
const { rows, totals } = buildPrAttribution(projects)
|
||||
const row = (n: number) => rows.find(r => r.url === PR(n))
|
||||
expect(row(1731)).toBeUndefined()
|
||||
const r1732 = row(1732)!
|
||||
const r1733 = row(1733)!
|
||||
expect(r1733.cost).toBeCloseTo(sessionCost('agent-aBuild') + s2Turns('2026-10-10T19:22', '2026-10-10T19:41'), 9)
|
||||
expect(r1732.cost).toBeCloseTo(sessionCost('agent-aResearch') + s2Turns('2026-10-10T15:17', '2026-10-10T19:22') + s2Turns('2026-10-10T19:41', '2026-10-10T21:00'), 9)
|
||||
// The open turn and the push to its head are evidence for #1732; the rest is the tag.
|
||||
expect(r1732.carriedUSD).toBeCloseTo(sessionCost('agent-aResearch') + s2Turns('2026-10-10T16:30', '2026-10-10T19:22') + s2Turns('2026-10-10T20:29', '2026-10-10T21:00'), 9)
|
||||
expect(r1733.carriedUSD).toBe(0)
|
||||
expect(r1732.runList.map(r => [r.kind, r.description, r.link])).toEqual([
|
||||
['session', null, 'opened'],
|
||||
['session', null, 'carried'],
|
||||
['subagent', 'Research PR view: one-shot, reviews', 'carried'],
|
||||
['session', null, 'gh-pr'],
|
||||
])
|
||||
expect(r1733.runList.map(r => [r.kind, r.description, r.link])).toEqual([
|
||||
['subagent', 'Build desktop PR card', 'pushed-head'],
|
||||
['session', null, 'opened'],
|
||||
])
|
||||
for (const r of rows) expect(r.runList.reduce((n, x) => n + x.costUSD, 0)).toBeCloseTo(r.cost, 9)
|
||||
// Reopened, still a draft (no gh pr ready).
|
||||
expect(r1732.state).toBe('draft')
|
||||
expect(r1732.openedAt).toBe('2026-10-10T15:18:42.000Z')
|
||||
expect(r1732.followUpRounds).toBe(1)
|
||||
expect(r1732.followUpAt).toEqual(['2026-10-10T19:41:52.000Z'])
|
||||
expect(r1733.state).toBeNull()
|
||||
// The build run's pushes came before #1733 opened: not follow-ups.
|
||||
expect(r1733.oneShot).toBe(true)
|
||||
expect(rows.reduce((s, r) => s + r.cost, 0)).toBeCloseTo(totals.attributedCost, 9)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue