/** * Branch-aware persistence for review progress: which files have been * viewed (and at what diff hash, so a new push invalidates the mark), * plus locally-drafted comments waiting to be posted. * * Mirrors the pattern used by the bundled `todo` extension: state lives * in the session itself (via `pi.appendEntry`), so it survives restarts * and follows session-tree branching for free. */ export interface DraftComment { path: string; line: number; body: string; } export interface MrState { /** path -> hash of the file diff when it was last marked viewed */ viewed: Record; drafts: DraftComment[]; } export interface ReviewSnapshot { version: 1; /** key: `${repo}#${mrId}` */ mrs: Record; } function emptyMrState(): MrState { return { viewed: {}, drafts: [] }; } function isMrState(value: unknown): value is MrState { if (!value || typeof value !== "object") return false; const candidate = value as { viewed?: unknown; drafts?: unknown }; return typeof candidate.viewed === "object" && candidate.viewed !== null && Array.isArray(candidate.drafts); } function parseSnapshot(value: unknown): ReviewSnapshot | undefined { if (!value || typeof value !== "object") return undefined; const candidate = value as { version?: unknown; mrs?: unknown }; if (candidate.version !== 1 || !candidate.mrs || typeof candidate.mrs !== "object") return undefined; const mrs: Record = {}; for (const [key, raw] of Object.entries(candidate.mrs as Record)) { if (isMrState(raw)) mrs[key] = raw; } return { version: 1, mrs }; } function snapshotFromEntry(entry: unknown): ReviewSnapshot | undefined { if (!entry || typeof entry !== "object") return undefined; const item = entry as { type?: string; customType?: string; data?: unknown }; if (item.type === "custom" && item.customType === "review-state") return parseSnapshot(item.data); return undefined; } export class ReviewStore { private mrs: Record = {}; restore(entries: readonly unknown[]): void { let latest: ReviewSnapshot | undefined; for (const entry of entries) { const snapshot = snapshotFromEntry(entry); if (snapshot) latest = snapshot; } this.mrs = latest ? structuredClone(latest.mrs) : {}; } getSnapshot(): ReviewSnapshot { return { version: 1, mrs: structuredClone(this.mrs) }; } private state(key: string): MrState { return (this.mrs[key] ??= emptyMrState()); } isViewed(key: string, path: string, currentHash: string): boolean { return this.mrs[key]?.viewed[path] === currentHash; } setViewed(key: string, path: string, hash: string, viewed: boolean): void { const state = this.state(key); if (viewed) state.viewed[path] = hash; else delete state.viewed[path]; } countViewed(key: string, paths: readonly string[], hashOf: (path: string) => string): number { const state = this.mrs[key]; if (!state) return 0; return paths.filter((path) => state.viewed[path] === hashOf(path)).length; } addDraft(key: string, draft: DraftComment): void { this.state(key).drafts.push(draft); } removeDraft(key: string, index: number): DraftComment | undefined { const drafts = this.state(key).drafts; const [removed] = drafts.splice(index, 1); return removed; } listDrafts(key: string): DraftComment[] { return [...(this.mrs[key]?.drafts ?? [])]; } clearDrafts(key: string): void { this.state(key).drafts = []; } } export function mrKey(repo: string, mrId: number): string { return `${repo}#${mrId}`; } /** Key for a local (no-forge) review: distinguishes branch/base pairs within the same repo. */ export function localReviewKey(repo: string, base: string, branch: string): string { return `local:${repo}#${base}..${branch}`; }