mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-26 00:44:41 +00:00
Review rounds 2-3 + self-review on --attribution: Credential egress (round 2): - normalizeRemoteUrl: scp userinfo expressed as an optional regex group let backtracking re-parse a credential prefix as host:path (x-access-token:ghp_...@host/repo -> token in git.repo). Userinfo is now split off at the first @ BEFORE any host matching. - Positive validation (allow-list) as the final gate on EVERY branch: host must be hostname-shaped, every path segment repo-shaped, total identity <= 200 chars. Kills transport-helper remotes (ext:: leaks local SSH key paths, codecommit:: leaks AWS profile names), residual @, spaces/colons, and unbounded strings. - sanitizePrLinks: links are rebuilt from origin + pathname — userinfo, query strings, and fragments are dropped instead of passed through; collapsed duplicates dedupe. Attribution correctness (round 3 + self-review): - Double-count fix with precise retraction semantics: when a commit migrates to a later-parsed tighter-window session, the loser re-emits git.commit_count=0. Empty records are emitted ONLY on a true loss in THIS computation (lostCandidacy) — a commit that merely aged out of the --since range was lost to nobody, and retracting it would permanently zero a still-correct server-side count. The sync layer additionally requires a prior ledgered state for the session. - Session dedup key includes project + both window timestamps, so ongoing sessions re-emit with corrected span times. - Span end times clamped like the usage builder (never 0, never earlier than start + 1ms). - CLI mirrors the usage path on attribution push failures instead of claiming success. - Identity normalization: case-insensitive .git strip, doubled path slashes collapse. AI-Origin: human
721 lines
26 KiB
TypeScript
721 lines
26 KiB
TypeScript
import { execFileSync } from 'child_process'
|
|
import { realpathSync } from 'fs'
|
|
import { resolve } from 'path'
|
|
import { parseAllSessions } from './parser.js'
|
|
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
|
|
|
|
export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous'
|
|
|
|
export type SessionYield = {
|
|
sessionId: string
|
|
project: string
|
|
cost: number
|
|
category: YieldCategory
|
|
commitCount: number
|
|
}
|
|
|
|
export type YieldSummary = {
|
|
productive: { cost: number; sessions: number }
|
|
reverted: { cost: number; sessions: number }
|
|
abandoned: { cost: number; sessions: number }
|
|
ambiguous: { cost: number; sessions: number }
|
|
total: { cost: number; sessions: number }
|
|
details: SessionYield[]
|
|
}
|
|
|
|
export type YieldJsonReport = {
|
|
period: {
|
|
label: string
|
|
start: string
|
|
end: string
|
|
}
|
|
summary: {
|
|
productive: YieldBucketJson
|
|
reverted: YieldBucketJson
|
|
abandoned: YieldBucketJson
|
|
ambiguous: YieldBucketJson
|
|
total: { costUSD: number; sessions: number }
|
|
productiveToRevertedCostRatio: number | null
|
|
}
|
|
methodology: 'timestamp-window'
|
|
details: SessionYieldJson[]
|
|
}
|
|
|
|
type YieldBucketJson = {
|
|
costUSD: number
|
|
sessions: number
|
|
costPercent: number
|
|
sessionPercent: number
|
|
}
|
|
|
|
type SessionYieldJson = Omit<SessionYield, 'cost'> & {
|
|
costUSD: number
|
|
}
|
|
|
|
const SAFE_REF_PATTERN = /^[A-Za-z0-9._/\-]+$/
|
|
|
|
function runGit(args: string[], cwd: string): string | null {
|
|
try {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
type RepoIdentity = {
|
|
/** Canonical group key: the absolute git-common-dir (shared object store). */
|
|
readonly key: string
|
|
/** A member directory to run `git log` from; --all spans the whole store. */
|
|
readonly gitDir: string
|
|
}
|
|
|
|
function canonicalPath(path: string): string {
|
|
try {
|
|
return realpathSync.native(path)
|
|
} catch {
|
|
return path
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a directory to its canonical repository identity, or null when it is
|
|
* not inside a git work tree.
|
|
*
|
|
* The key is the absolute `git-common-dir` (the shared object store), NOT the
|
|
* raw path: `git rev-parse --is-inside-work-tree` is true in every subdirectory
|
|
* and `git log --all` returns the same repo-wide list from any of them, so
|
|
* keying groups on the raw path would let two monorepo subdirectories — or two
|
|
* worktrees of one repo — each award the same commit. All subdirectories AND
|
|
* all worktrees of one repo share this common-dir, so they collapse to a single
|
|
* group; distinct repositories keep distinct keys.
|
|
*
|
|
* Cached per directory: resolution runs once per unique path (many sessions
|
|
* share a path), never once per session.
|
|
*/
|
|
function resolveRepoIdentity(
|
|
dir: string,
|
|
cache: Map<string, RepoIdentity | null>,
|
|
): RepoIdentity | null {
|
|
const cached = cache.get(dir)
|
|
if (cached !== undefined) return cached
|
|
|
|
let identity: RepoIdentity | null = null
|
|
// One fork answers both questions; line 1 is --is-inside-work-tree, line 2 the
|
|
// --git-common-dir (relative to `dir` for a subdir, absolute for a worktree).
|
|
const out = runGit(['rev-parse', '--is-inside-work-tree', '--git-common-dir'], dir)
|
|
if (out) {
|
|
const [insideWorkTree, commonDir] = out.split('\n')
|
|
if (insideWorkTree === 'true' && commonDir) {
|
|
// realpath canonicalizes symlinks: git reports a linked worktree's
|
|
// common-dir as a realpath but the main worktree's as ".git" relative to
|
|
// its (possibly symlinked) path, so both must be canonicalized to collapse
|
|
// to one key.
|
|
// Accepted residual: moving the main repo after `git worktree add` leaves
|
|
// Git's linked-worktree metadata stale, so its worktrees may not unify;
|
|
// that is Git-state staleness, not a grouping bug.
|
|
identity = { key: canonicalPath(resolve(dir, commonDir)), gitDir: dir }
|
|
}
|
|
}
|
|
cache.set(dir, identity)
|
|
return identity
|
|
}
|
|
|
|
function getMainBranch(cwd: string): string {
|
|
const result = runGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd)
|
|
if (result) {
|
|
const branch = result.replace('refs/remotes/origin/', '')
|
|
if (SAFE_REF_PATTERN.test(branch)) return branch
|
|
}
|
|
|
|
const branches = runGit(['branch', '-a'], cwd) ?? ''
|
|
if (branches.includes('main')) return 'main'
|
|
if (branches.includes('master')) return 'master'
|
|
return 'main'
|
|
}
|
|
|
|
/**
|
|
* Normalize a git remote URL to a host-scoped repo identity (`host/org/repo`)
|
|
* usable as a server-side join key. Handles the three common transports:
|
|
*
|
|
* git@github.com:org/repo.git -> github.com/org/repo
|
|
* ssh://git@github.com:22/org/repo.git -> github.com/org/repo
|
|
* https://user:tok@github.com/org/repo.git -> github.com/org/repo
|
|
*
|
|
* Credentials and ports are stripped (a token embedded in an https remote must
|
|
* never leave the machine), the host is lowercased (path case is preserved),
|
|
* and a trailing `.git` / `/` is removed. Local paths and `file://` remotes
|
|
* return null — a repo with no network remote has no server-side identity.
|
|
*/
|
|
/** Max length of an emitted repo identity (`host/org/repo`). */
|
|
const MAX_REPO_IDENTITY_LENGTH = 200
|
|
|
|
/**
|
|
* Positive validation (allow-list) of a composed repo identity — the final
|
|
* gate EVERY branch passes through before anything is returned. The host must
|
|
* look like a hostname and every path segment like a repo path segment, so no
|
|
* upstream parsing quirk (transport-helper remotes like `ext::…` or
|
|
* `codecommit::…`, credentials that survived a malformed URL, oversized
|
|
* strings) can reach the wire. Rejecting is always safe: an unrecognizable
|
|
* remote simply has no server-side identity.
|
|
*/
|
|
function isValidRepoIdentity(host: string, segments: string[]): boolean {
|
|
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) return false
|
|
if (segments.length === 0) return false
|
|
return segments.every(s => /^[A-Za-z0-9._~-]+$/.test(s))
|
|
}
|
|
|
|
export function normalizeRemoteUrl(url: string): string | null {
|
|
const trimmed = url.trim()
|
|
if (!trimmed) return null
|
|
|
|
// Windows drive-letter paths (`C:\Users\...`, `C:/Users/...`) are local
|
|
// filesystem paths, not scp-like remotes — without this check the scp-like
|
|
// branch would parse `C:` as a host and emit the user's local path as a
|
|
// repo identity.
|
|
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return null
|
|
|
|
let host: string
|
|
let path: string
|
|
|
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(trimmed)
|
|
} catch {
|
|
return null
|
|
}
|
|
if (parsed.protocol === 'file:') return null
|
|
if (!parsed.hostname) return null
|
|
host = parsed.hostname
|
|
path = parsed.pathname
|
|
} else {
|
|
// scp-like syntax: [user@]host:path. Credentials (userinfo) are split off
|
|
// at the FIRST `@` BEFORE any host matching — expressing userinfo as an
|
|
// optional regex group lets backtracking abandon the group and re-parse a
|
|
// credential prefix as `host:path`, dumping the token into the path
|
|
// (e.g. `x-access-token:ghp_…@github.com/org/repo`). Host must be at
|
|
// least 2 chars: a single-character "host" is a Windows drive-relative
|
|
// path (`C:repo`), never a real remote host.
|
|
const at = trimmed.indexOf('@')
|
|
const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed
|
|
const scpLike = /^([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(rest)
|
|
if (!scpLike) return null
|
|
host = scpLike[1]
|
|
path = scpLike[2]
|
|
}
|
|
|
|
const cleanPath = path
|
|
.replace(/\/+/g, '/') // collapse doubled slashes → one join key, not two
|
|
.replace(/^\/+/, '')
|
|
.replace(/\/+$/, '')
|
|
.replace(/\.git$/i, '') // case-insensitive: Repo.GIT joins with repo.git
|
|
if (!cleanPath) return null
|
|
|
|
const identity = `${host.toLowerCase()}/${cleanPath}`
|
|
if (identity.length > MAX_REPO_IDENTITY_LENGTH) return null
|
|
if (!isValidRepoIdentity(host.toLowerCase(), cleanPath.split('/'))) return null
|
|
|
|
return identity
|
|
}
|
|
|
|
/** `git remote get-url origin`, normalized. Null when absent or local-only. */
|
|
function getRepoRemote(gitDir: string): string | null {
|
|
const url = runGit(['remote', 'get-url', 'origin'], gitDir)
|
|
return url ? normalizeRemoteUrl(url) : null
|
|
}
|
|
|
|
export type CommitInfo = {
|
|
sha: string
|
|
timestamp: Date
|
|
inMain: boolean
|
|
/** Set when a LATER commit's body says "This reverts commit <sha>" — i.e. the work in this commit was reverted out of main. */
|
|
wasReverted: boolean
|
|
}
|
|
|
|
type SessionWindow = {
|
|
readonly start: Date
|
|
readonly end: Date
|
|
readonly sessionId: string
|
|
}
|
|
|
|
type SessionAttribution = {
|
|
readonly window: SessionWindow | null
|
|
readonly commits: CommitInfo[]
|
|
lostCandidacy: boolean
|
|
}
|
|
|
|
/**
|
|
* Find SHAs that were the target of a `git revert` ANYWHERE in the repo's
|
|
* history (not just the time window). The standard `git revert` body
|
|
* format is "This reverts commit <SHA>." which we grep out.
|
|
*
|
|
* The previous implementation flagged a commit as `isRevert` based on the
|
|
* substring "revert" appearing in its OWN subject. Two bugs there:
|
|
* 1. Subjects like "Add revert button" matched.
|
|
* 2. The session that PERFORMED the revert was tagged "reverted", not the
|
|
* session whose work was being reverted — so the original session always
|
|
* looked productive even after its work was thrown away.
|
|
*/
|
|
function getRevertedShas(cwd: string): Set<string> {
|
|
const bodies = runGit(
|
|
['log', '--all', '--grep=^This reverts commit', '--format=%B%x1e'],
|
|
cwd,
|
|
) ?? ''
|
|
const set = new Set<string>()
|
|
const re = /This reverts commit ([0-9a-f]{7,40})/g
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(bodies)) !== null) {
|
|
set.add(m[1].toLowerCase())
|
|
}
|
|
return set
|
|
}
|
|
|
|
function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: string): CommitInfo[] {
|
|
const sinceStr = since.toISOString()
|
|
const untilStr = until.toISOString()
|
|
|
|
const log = runGit(
|
|
['log', '--all', `--since=${sinceStr}`, `--until=${untilStr}`, '--format=%H|%aI|%s'],
|
|
cwd
|
|
)
|
|
|
|
if (!log) return []
|
|
|
|
const mainCommits = new Set(
|
|
(runGit(['log', mainBranch, '--format=%H'], cwd) ?? '').split('\n').filter(Boolean)
|
|
)
|
|
const revertedShas = getRevertedShas(cwd)
|
|
|
|
return log.split('\n').filter(Boolean).map(line => {
|
|
const [sha] = line.split('|')
|
|
const timestamp = line.split('|')[1] ?? ''
|
|
return {
|
|
sha,
|
|
timestamp: new Date(timestamp),
|
|
inMain: mainCommits.has(sha),
|
|
// wasReverted: matches when ANY later commit's body says
|
|
// "This reverts commit <sha>". Compare against the full SHA AND its
|
|
// 7-char short prefix to be safe; git revert sometimes records the
|
|
// short form.
|
|
wasReverted: revertedShas.has(sha.toLowerCase()) ||
|
|
revertedShas.has(sha.toLowerCase().slice(0, 7)),
|
|
}
|
|
})
|
|
}
|
|
|
|
function sessionWindow(session: SessionSummary): SessionWindow | null {
|
|
if (!session.firstTimestamp) return null
|
|
|
|
const start = new Date(session.firstTimestamp)
|
|
const lastTs = session.lastTimestamp ?? session.firstTimestamp
|
|
const end = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000)
|
|
return { start, end, sessionId: session.sessionId }
|
|
}
|
|
|
|
function attributeCommits(
|
|
sessions: SessionSummary[],
|
|
commits: CommitInfo[],
|
|
): SessionAttribution[] {
|
|
const attributions: SessionAttribution[] = sessions.map(session => ({
|
|
window: sessionWindow(session),
|
|
commits: [],
|
|
lostCandidacy: false,
|
|
}))
|
|
|
|
for (const commit of commits) {
|
|
const candidates = attributions.filter((attribution): attribution is SessionAttribution & { window: SessionWindow } =>
|
|
attribution.window !== null &&
|
|
commit.timestamp >= attribution.window.start &&
|
|
commit.timestamp <= attribution.window.end,
|
|
)
|
|
|
|
const owner = candidates.reduce<SessionAttribution & { window: SessionWindow } | null>((current, candidate) => {
|
|
if (current === null) return candidate
|
|
|
|
const currentSpan = current.window.end.getTime() - current.window.start.getTime()
|
|
const candidateSpan = candidate.window.end.getTime() - candidate.window.start.getTime()
|
|
if (candidateSpan !== currentSpan) return candidateSpan < currentSpan ? candidate : current
|
|
if (candidate.window.start.getTime() !== current.window.start.getTime()) {
|
|
return candidate.window.start < current.window.start ? candidate : current
|
|
}
|
|
// sessionId, not array position: session order is cache-state dependent.
|
|
return candidate.window.sessionId < current.window.sessionId ? candidate : current
|
|
}, null)
|
|
|
|
for (const candidate of candidates) {
|
|
if (candidate !== owner) candidate.lostCandidacy = true
|
|
}
|
|
owner?.commits.push(commit)
|
|
}
|
|
|
|
return attributions
|
|
}
|
|
|
|
function categorizeSession(
|
|
session: SessionSummary,
|
|
commits: CommitInfo[],
|
|
lostCandidacy: boolean,
|
|
): { category: YieldCategory; commitCount: number } {
|
|
if (!session.firstTimestamp) {
|
|
return { category: 'abandoned', commitCount: 0 }
|
|
}
|
|
|
|
if (commits.length === 0) {
|
|
// Ambiguous only when this session's window actually contained a commit
|
|
// that a tighter window won — mere overlap with a credited session is not
|
|
// enough to withhold the abandoned classification.
|
|
return {
|
|
category: lostCandidacy ? 'ambiguous' : 'abandoned',
|
|
commitCount: 0,
|
|
}
|
|
}
|
|
|
|
const inMainCount = commits.filter(c => c.inMain).length
|
|
// A session is "reverted" when at least half of its in-main commits were
|
|
// later reverted out (revert detected via "This reverts commit <sha>"
|
|
// anywhere later in history, not in the same time window).
|
|
const revertedCount = commits.filter(c => c.inMain && c.wasReverted).length
|
|
|
|
if (revertedCount > 0 && revertedCount >= inMainCount / 2) {
|
|
return { category: 'reverted', commitCount: commits.length }
|
|
}
|
|
|
|
if (inMainCount > 0) {
|
|
return { category: 'productive', commitCount: inMainCount }
|
|
}
|
|
|
|
return { category: 'abandoned', commitCount: commits.length }
|
|
}
|
|
|
|
type RepoGroup = {
|
|
commits: CommitInfo[]
|
|
sessions: SessionSummary[]
|
|
projectNames: string[]
|
|
/** Parallel to `sessions`: true when the session's identity came from its
|
|
* OWN project path; false when it inherited the cwd-fallback identity.
|
|
* The attribution (sync) path must never egress fallback-derived repos. */
|
|
ownIdentity: boolean[]
|
|
/** A directory to run further git queries in (remote lookup); null when the group has no git identity. */
|
|
gitDir: string | null
|
|
}
|
|
|
|
/**
|
|
* Group sessions by canonical repository identity and load each group's
|
|
* commits for the range. Shared by `computeYield` (categorization) and
|
|
* `computeAttributionRecords` (sync). Grouping semantics are unchanged from
|
|
* the original computeYield implementation: each commit is awarded at most
|
|
* once across the whole repo; monorepo subdirectories and worktrees collapse
|
|
* to one group; a project whose path is missing or not a git repo falls back
|
|
* to the cwd repo (or an empty commit list when cwd is not a repo either).
|
|
*/
|
|
function buildRepoGroups(
|
|
projects: ProjectSummary[],
|
|
range: DateRange,
|
|
cwd: string,
|
|
): Map<string, RepoGroup> {
|
|
const repoIdentityCache = new Map<string, RepoIdentity | null>()
|
|
|
|
const cwdIdentity = resolveRepoIdentity(cwd, repoIdentityCache)
|
|
const cwdCommits = cwdIdentity
|
|
? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd))
|
|
: []
|
|
|
|
const repoGroups = new Map<string, RepoGroup>()
|
|
for (const project of projects) {
|
|
const projectIdentity = project.projectPath
|
|
? resolveRepoIdentity(project.projectPath, repoIdentityCache)
|
|
: null
|
|
const identity = projectIdentity ?? cwdIdentity
|
|
const groupKey = identity ? identity.key : cwd
|
|
|
|
let group = repoGroups.get(groupKey)
|
|
if (!group) {
|
|
group = {
|
|
commits: !identity
|
|
? []
|
|
: cwdIdentity && identity.key === cwdIdentity.key
|
|
? cwdCommits
|
|
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
|
|
sessions: [],
|
|
projectNames: [],
|
|
ownIdentity: [],
|
|
gitDir: identity?.gitDir ?? null,
|
|
}
|
|
repoGroups.set(groupKey, group)
|
|
}
|
|
for (const session of project.sessions) {
|
|
group.sessions.push(session)
|
|
group.projectNames.push(project.project)
|
|
group.ownIdentity.push(projectIdentity !== null)
|
|
}
|
|
}
|
|
|
|
return repoGroups
|
|
}
|
|
|
|
export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise<YieldSummary> {
|
|
const projects = await parseAllSessions(range, provider)
|
|
|
|
const summary: YieldSummary = {
|
|
productive: { cost: 0, sessions: 0 },
|
|
reverted: { cost: 0, sessions: 0 },
|
|
abandoned: { cost: 0, sessions: 0 },
|
|
ambiguous: { cost: 0, sessions: 0 },
|
|
total: { cost: 0, sessions: 0 },
|
|
details: [],
|
|
}
|
|
|
|
const repoGroups = buildRepoGroups(projects, range, cwd)
|
|
|
|
for (const group of repoGroups.values()) {
|
|
const attributions = attributeCommits(group.sessions, group.commits)
|
|
for (const [index, session] of group.sessions.entries()) {
|
|
const attribution = attributions[index]
|
|
const { category, commitCount } = categorizeSession(
|
|
session,
|
|
attribution?.commits ?? [],
|
|
attribution?.lostCandidacy ?? false,
|
|
)
|
|
|
|
summary[category].cost += session.totalCostUSD
|
|
summary[category].sessions += 1
|
|
summary.total.cost += session.totalCostUSD
|
|
summary.total.sessions += 1
|
|
|
|
summary.details.push({
|
|
sessionId: session.sessionId,
|
|
project: group.projectNames[index] ?? session.project,
|
|
cost: session.totalCostUSD,
|
|
category,
|
|
commitCount,
|
|
})
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
export function formatYieldSummary(summary: YieldSummary): string {
|
|
const { productive, reverted, abandoned, ambiguous, total } = summary
|
|
|
|
const pct = (n: number) => total.cost > 0 ? Math.round((n / total.cost) * 100) : 0
|
|
const fmt = (n: number) => `$${n.toFixed(2)}`
|
|
|
|
const lines = [
|
|
'',
|
|
`Productive: ${fmt(productive.cost).padStart(8)} (${pct(productive.cost)}%) - ${productive.sessions} sessions shipped to main`,
|
|
`Reverted: ${fmt(reverted.cost).padStart(8)} (${pct(reverted.cost)}%) - ${reverted.sessions} sessions were reverted`,
|
|
`Abandoned: ${fmt(abandoned.cost).padStart(8)} (${pct(abandoned.cost)}%) - ${abandoned.sessions} sessions never committed`,
|
|
`Ambiguous: ${fmt(ambiguous.cost).padStart(8)} (${pct(ambiguous.cost)}%) - ${ambiguous.sessions} sessions lost commits to concurrent sessions`,
|
|
'',
|
|
'Attribution: timestamp-window based (heuristic)',
|
|
'',
|
|
`Total: ${fmt(total.cost).padStart(8)} - ${total.sessions} sessions`,
|
|
'',
|
|
]
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function buildYieldJsonReport(
|
|
summary: YieldSummary,
|
|
periodLabel: string,
|
|
range: DateRange,
|
|
): YieldJsonReport {
|
|
const bucket = (value: { cost: number; sessions: number }): YieldBucketJson => ({
|
|
costUSD: value.cost,
|
|
sessions: value.sessions,
|
|
costPercent: summary.total.cost > 0
|
|
? Math.round((value.cost / summary.total.cost) * 1000) / 10
|
|
: 0,
|
|
sessionPercent: summary.total.sessions > 0
|
|
? Math.round((value.sessions / summary.total.sessions) * 1000) / 10
|
|
: 0,
|
|
})
|
|
|
|
return {
|
|
period: {
|
|
label: periodLabel,
|
|
start: range.start.toISOString(),
|
|
end: range.end.toISOString(),
|
|
},
|
|
summary: {
|
|
productive: bucket(summary.productive),
|
|
reverted: bucket(summary.reverted),
|
|
abandoned: bucket(summary.abandoned),
|
|
ambiguous: bucket(summary.ambiguous),
|
|
total: {
|
|
costUSD: summary.total.cost,
|
|
sessions: summary.total.sessions,
|
|
},
|
|
productiveToRevertedCostRatio: summary.reverted.cost > 0
|
|
? Math.round((summary.productive.cost / summary.reverted.cost) * 100) / 100
|
|
: null,
|
|
},
|
|
methodology: 'timestamp-window',
|
|
details: summary.details.map(detail => ({
|
|
sessionId: detail.sessionId,
|
|
project: detail.project,
|
|
costUSD: detail.cost,
|
|
category: detail.category,
|
|
commitCount: detail.commitCount,
|
|
})),
|
|
}
|
|
}
|
|
|
|
// --- Sync attribution (codeburn sync push --attribution) ---
|
|
|
|
export type CommitAttribution = {
|
|
sha: string
|
|
/** Commit author time, ISO 8601. */
|
|
timestamp: string
|
|
inMain: boolean
|
|
wasReverted: boolean
|
|
}
|
|
|
|
/**
|
|
* Per-session git attribution record — the sync-facing projection of the
|
|
* yield timestamp-window correlation. One record per session that produced
|
|
* server-joinable evidence: attributed commits (requires a normalized remote)
|
|
* and/or PR links.
|
|
*/
|
|
export type SessionAttributionRecord = {
|
|
sessionId: string
|
|
project: string
|
|
/** Normalized origin remote (`host/org/repo`), the server-side join key. Null when only prLinks are available. */
|
|
repo: string | null
|
|
/** GitHub PR URLs captured for the session (already normalized upstream). */
|
|
prLinks: string[]
|
|
/** Commits attributed to this session. Empty when repo is null (SHAs without a repo identity cannot be joined). */
|
|
commits: CommitAttribution[]
|
|
/** Session window, ISO 8601 — lets the receiver reason about attribution recency. */
|
|
firstTimestamp: string
|
|
lastTimestamp: string
|
|
}
|
|
|
|
/** Max PR links retained per session attribution record. */
|
|
export const MAX_PR_LINKS_PER_SESSION = 20
|
|
|
|
/**
|
|
* Shape-check PR links before they leave the machine. Upstream parsers only
|
|
* verify truthiness, so arbitrary strings can land in `session.prLinks`.
|
|
* Keep only https URLs shaped like a PR (`/org/repo/pull/N` — GitHub and
|
|
* GitHub Enterprise), bounded in length, capped per session, sorted.
|
|
*
|
|
* Links are REBUILT from `origin + pathname`, never passed through verbatim:
|
|
* userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub
|
|
* links routinely carry `?notification_referrer_id=…`), and fragments are
|
|
* all dropped. Rebuilt links that collapse to the same URL dedupe.
|
|
*/
|
|
export function sanitizePrLinks(links: string[]): string[] {
|
|
const valid = new Set<string>()
|
|
for (const link of links) {
|
|
if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue
|
|
let url: URL
|
|
try {
|
|
url = new URL(link)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (url.protocol !== 'https:') continue
|
|
if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
|
|
const rebuilt = `${url.origin}${url.pathname}`
|
|
if (rebuilt.length > 256) continue
|
|
valid.add(rebuilt)
|
|
}
|
|
return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION)
|
|
}
|
|
|
|
/**
|
|
* Compute per-session attribution records for sync. Reuses the exact yield
|
|
* repo grouping + tightest-window commit attribution (`methodology:
|
|
* timestamp-window`), then joins in each repo group's normalized origin
|
|
* remote and the session's sanitized PR links.
|
|
*
|
|
* Inclusion rules:
|
|
* - Only sessions whose OWN project path resolved to a repo participate in
|
|
* commit attribution; cwd-fallback sessions never carry a repo or commits
|
|
* (privacy gate — see below) but still emit a record when they have PR links.
|
|
* - Commits require a normalized remote; a SHA without a repo identity has
|
|
* no server-side meaning.
|
|
* - A session with no commits and no PR links is emitted ONLY when it lost a
|
|
* commit to a tighter-window session in THIS computation (`lostCandidacy`)
|
|
* — a retraction candidate. A session that merely aged its commits out of
|
|
* the range lost them to nobody, and emitting an empty record for it would
|
|
* permanently retract a still-correct server-side count.
|
|
*
|
|
* Takes already-parsed projects (sync push has them in hand) instead of
|
|
* re-parsing like computeYield does.
|
|
*/
|
|
export function computeAttributionRecords(
|
|
projects: ProjectSummary[],
|
|
range: DateRange,
|
|
cwd: string,
|
|
): SessionAttributionRecord[] {
|
|
const repoGroups = buildRepoGroups(projects, range, cwd)
|
|
const records: SessionAttributionRecord[] = []
|
|
|
|
for (const group of repoGroups.values()) {
|
|
const remote = group.gitDir ? getRepoRemote(group.gitDir) : null
|
|
|
|
// Privacy gate: only sessions whose identity came from their OWN project
|
|
// path participate in commit attribution. A session whose project path no
|
|
// longer resolves (deleted/renamed dir, non-repo session) inherits the
|
|
// cwd-fallback identity in buildRepoGroups — attributing it here would
|
|
// egress whatever repo the user happens to be pushing from, with commits
|
|
// that session never touched. Fallback sessions get no repo and no
|
|
// commits; they still emit a record when they carry PR links (which are
|
|
// session-native and safe). Excluding them from the competition also
|
|
// prevents a fallback window from stealing a commit that belongs to a
|
|
// genuine session.
|
|
const ownSessions = group.sessions.filter((_, i) => group.ownIdentity[i])
|
|
const attributions = attributeCommits(ownSessions, group.commits)
|
|
// Keyed by object reference: session objects are unique per group entry,
|
|
// whereas sessionId strings could collide across projects.
|
|
const attributionBySession = new Map<SessionSummary, CommitInfo[]>()
|
|
const lostCandidacyBySession = new Map<SessionSummary, boolean>()
|
|
for (const [i, session] of ownSessions.entries()) {
|
|
attributionBySession.set(session, attributions[i]?.commits ?? [])
|
|
lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false)
|
|
}
|
|
|
|
for (const [index, session] of group.sessions.entries()) {
|
|
if (!session.firstTimestamp) continue
|
|
|
|
const isOwn = group.ownIdentity[index] === true
|
|
const sessionRemote = isOwn ? remote : null
|
|
const attributedCommits = sessionRemote
|
|
? (attributionBySession.get(session) ?? [])
|
|
: []
|
|
const prLinks = sanitizePrLinks(session.prLinks ?? [])
|
|
// Empty sessions are retraction candidates ONLY when they lost a commit
|
|
// to a tighter-window session in THIS run: that commit's server-side
|
|
// attribution is migrating, so the loser must re-emit commit_count=0.
|
|
// An empty session whose commits merely aged out of the --since range
|
|
// (rolling window, or a narrower window than a previous push) lost them
|
|
// to NOBODY — emitting a retraction for it would permanently zero a
|
|
// still-correct server-side count, because the original state key stays
|
|
// ledgered and is never re-sent.
|
|
const lostToTighterSession = sessionRemote !== null &&
|
|
(lostCandidacyBySession.get(session) ?? false)
|
|
if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue
|
|
|
|
records.push({
|
|
sessionId: session.sessionId,
|
|
project: group.projectNames[index] ?? session.project,
|
|
repo: sessionRemote,
|
|
prLinks,
|
|
commits: attributedCommits.map(c => ({
|
|
sha: c.sha,
|
|
timestamp: c.timestamp.toISOString(),
|
|
inMain: c.inMain,
|
|
wasReverted: c.wasReverted,
|
|
})),
|
|
firstTimestamp: session.firstTimestamp,
|
|
lastTimestamp: session.lastTimestamp ?? session.firstTimestamp,
|
|
})
|
|
}
|
|
}
|
|
|
|
return records
|
|
}
|