"use client"; import { hashDiff, normalizeDiffContent, reviewFileKey, splitReviewFileKey, } from "@/components/review/types"; import type { FileInfo } from "@/lib/state/store"; import type { PRDiffFile } from "@/lib/types/github"; import { normalizeFileChangeStatus, type FileChangeStatus } from "@/lib/utils/file-change-status"; import type { PRChangedFile } from "./changes-panel-timeline"; import type { ChangeLayer, CommitDetailTarget } from "./changes-diff-target"; import type { CommitPresentation } from "./commit-row"; export type ChangedFile = { isSymlink?: boolean; path: string; status: FileChangeStatus; staged: boolean; plus: number | undefined; minus: number | undefined; oldPath: string | undefined; /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ repositoryName?: string; changeLayer?: ChangeLayer; }; /** * Picks the group label stamped onto each PR's files so the changes panel * can show one section per PR for multi-branch tasks. * * Rules: * - Only one PR on the task -> no stamp (the section header is enough). * - Multi-repo, one PR per repo -> stamp the repo name (legacy behavior). * - Any repo has multiple PRs -> append the branch / PR number so the * group label disambiguates which PR each file belongs to. */ export function computePRGroupStamp(args: { needsStamp: boolean; taskHasMultipleRepos: boolean; anyRepoMultiPR: boolean; repoName: string; branch: string; prNumber: number; }): string { if (!args.needsStamp) return ""; const label = args.branch || `PR #${args.prNumber}`; if (args.anyRepoMultiPR && args.taskHasMultipleRepos) { return `${args.repoName} · ${label}`; } if (args.anyRepoMultiPR) { return label; } return args.repoName; } export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { return files.map((file) => ({ path: file.path, status: file.status, staged: file.staged, plus: file.additions, minus: file.deletions, oldPath: file.old_path, repositoryName: file.repository_name, changeLayer: file.change_layer, isSymlink: file.is_symlink, })); } type CumulativeDiffFiles = Record< string, { path?: string; repository_name?: string; diff?: string; status?: string; additions?: number; deletions?: number; } >; function cumulativeFileIdentity( mapKey: string, file: CumulativeDiffFiles[string], useRepositoryKeys: boolean, ) { const path = file.path ?? splitReviewFileKey(mapKey).path; const repositoryName = useRepositoryKeys ? file.repository_name : undefined; return { key: reviewFileKey({ path, repository_name: repositoryName }), path, repositoryName, }; } export type ReviewProgressPRFile = PRDiffFile & { repository_name?: string }; export type ReviewProgressPRSource = { repositoryName: string; files: PRDiffFile[]; }; export function selectPRFilesForReviewProgress( sourcesByPRID: ReadonlyMap, primaryPRID: string | undefined, useRepositoryKeys: boolean, ): ReviewProgressPRFile[] { if (!primaryPRID) return []; const source = sourcesByPRID.get(primaryPRID); if (!source) return []; return source.files.map((file) => ({ ...file, repository_name: useRepositoryKeys ? source.repositoryName : undefined, })); } function addReviewSource( winningDiffs: Map, paths: Set, source: { key: string; path: string; repositoryName?: string; diff?: string; }, ): void { const isScoped = source.repositoryName !== undefined; const collidesWithHigherPriority = isScoped ? source.repositoryName !== "" && winningDiffs.has(source.path) : paths.has(source.path); if (winningDiffs.has(source.key) || collidesWithHigherPriority) return; winningDiffs.set(source.key, source.diff); if (!isScoped) paths.add(source.path); } function buildReviewProgressIndex( uncommittedFiles: FileInfo[], cumulativeDiffFiles: CumulativeDiffFiles | undefined, prFiles?: ReviewProgressPRFile[], useRepositoryKeys = true, ): Map { const winningDiffs = new Map(); const paths = new Set(); for (const file of uncommittedFiles) { const repositoryName = useRepositoryKeys ? file.repository_name : undefined; const key = reviewFileKey({ path: file.path, repository_name: repositoryName }); addReviewSource(winningDiffs, paths, { key, path: file.path, repositoryName, diff: file.diff, }); } if (cumulativeDiffFiles) { for (const [mapKey, file] of Object.entries(cumulativeDiffFiles)) { const identity = cumulativeFileIdentity(mapKey, file, useRepositoryKeys); addReviewSource(winningDiffs, paths, { ...identity, diff: file.diff, }); } } if (prFiles) { for (const file of prFiles) { const repositoryName = useRepositoryKeys ? file.repository_name : undefined; const key = reviewFileKey({ path: file.filename, repository_name: repositoryName, }); addReviewSource(winningDiffs, paths, { key, path: file.filename, repositoryName, diff: file.patch, }); } } return winningDiffs; } export function computeReviewProgress( uncommittedFiles: FileInfo[], cumulativeDiff: { files?: CumulativeDiffFiles } | null, reviews: Map, prFiles?: ReviewProgressPRFile[], useRepositoryKeys = true, ) { const cumulativeDiffFiles = cumulativeDiff?.files; const winningDiffs = buildReviewProgressIndex( uncommittedFiles, cumulativeDiffFiles, prFiles, useRepositoryKeys, ); let reviewed = 0; for (const [key, diff] of winningDiffs) { const state = reviews.get(key); if (!state?.reviewed) continue; const diffContent = normalizeDiffContent(diff ?? ""); if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; reviewed++; } return { reviewedCount: reviewed, totalFileCount: winningDiffs.size }; } export function computeStagedStats(stagedFiles: FileInfo[]) { const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0); const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0); return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels }; } export function mapPRFilesToChangedFiles( files: PRDiffFile[], repositoryName?: string, prKey?: string, ): PRChangedFile[] { return files.map((file) => ({ path: file.filename, prKey, status: normalizeFileChangeStatus(file.status), plus: file.additions, minus: file.deletions, oldPath: file.old_path, repository_name: repositoryName ?? "", })); } export function filterUnpushedCommits( localCommits: T[], prCommits: { sha: string }[], ): T[] { if (prCommits.length === 0) return localCommits; return localCommits.filter( (c) => !prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)), ); } /** Which timeline section, if any, renders first (topmost) in the Changes panel. */ type FirstVisibleSection = "pr" | "unstaged" | "staged" | "commits" | null; /** PR Changes auto-expands only when the diff is small enough to scan inline. */ export const PR_CHANGES_AUTO_EXPAND_MAX_FILES = 5; /** * Computes the first (topmost) visible section so the panel can auto-expand it, * mirroring the render precedence in `ChangesPanelTimeline`: * PR (review mode, no local changes) → Unstaged → Staged → Commits. * * The "review mode" PR section only sits at the top when there are no local * working-tree changes; with local changes the PR section moves below Staged and * is therefore never first. Large PR diffs (>5 files) skip auto-expanding PR * Changes and expand Commits instead. Returns null when nothing is shown. */ export function firstVisibleSection(flags: { hasPRFiles: boolean; hasUnstaged: boolean; hasStaged: boolean; showCommitsList: boolean; prFileCount?: number; }): FirstVisibleSection { const { hasPRFiles, hasUnstaged, hasStaged, showCommitsList, prFileCount = 0 } = flags; if (hasPRFiles && !hasUnstaged && !hasStaged) { if (prFileCount <= PR_CHANGES_AUTO_EXPAND_MAX_FILES) return "pr"; if (showCommitsList) return "commits"; return null; } if (hasUnstaged) return "unstaged"; if (hasStaged) return "staged"; if (showCommitsList) return "commits"; return null; } export type MergedCommit = { commit_sha: string; commit_message: string; insertions: number; deletions: number; pushed: boolean; statsAvailable: boolean; detailTarget: CommitDetailTarget; /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ repository_name?: string; committed_at?: string; presentation?: CommitPresentation; }; export type PRCommitForMerge = { sha: string; message: string; additions: number; deletions: number; author_date?: string; stats_available?: boolean; workspace_id: string; owner: string; repo: string; repository_name?: string; }; function localCommitTarget(commit: { commit_sha: string; repository_name?: string; }): CommitDetailTarget { return { source: "local", sha: commit.commit_sha, ...(commit.repository_name ? { repo: commit.repository_name } : {}), }; } function githubCommitTarget(commit: PRCommitForMerge): CommitDetailTarget | null { if (!commit.workspace_id.trim() || !commit.owner.trim() || !commit.repo.trim()) return null; return { source: "github", sha: commit.sha, workspaceId: commit.workspace_id, owner: commit.owner, repo: commit.repo, ...(commit.repository_name ? { repositoryName: commit.repository_name } : {}), }; } function prCommitKey(pr: PRCommitForMerge): string { return `${pr.repository_name ?? ""}\0${pr.sha}`; } function appendPROnlyCommits( prCommits: PRCommitForMerge[], matchedPRKeys: Set, pushed: MergedCommit[], ): void { // The provider endpoint returns its complete history oldest-first. The // Changes timeline is newest-first, so provider-only rows must be reversed // before they are placed ahead of shared checkout history. for (let index = prCommits.length - 1; index >= 0; index--) { const pr = prCommits[index]; if (matchedPRKeys.has(prCommitKey(pr))) continue; const mapped = mapCurrentPRCommit(pr); if (mapped) pushed.push(mapped); } } type LocalCommitForMerge = { commit_sha: string; commit_message: string; insertions: number; deletions: number; repository_name?: string; pushed?: boolean; committed_at?: string; }; type LocalMergeBuckets = { unpushed: MergedCommit[]; pushed: MergedCommit[]; matchedPRKeys: Set; localPushedByPRKey: Map; }; function commitsMatch( local: Pick, pr: PRCommitForMerge, ): boolean { if (!local.commit_sha || !pr.sha) return false; const shaMatches = pr.sha.startsWith(local.commit_sha) || local.commit_sha.startsWith(pr.sha); return shaMatches && (local.repository_name ?? "") === (pr.repository_name ?? ""); } function collectLocalCommits( localCommits: LocalCommitForMerge[], prCommits: PRCommitForMerge[], ): LocalMergeBuckets { const buckets: LocalMergeBuckets = { unpushed: [], pushed: [], matchedPRKeys: new Set(), localPushedByPRKey: new Map(), }; for (const commit of localCommits) { const matchingPRs = prCommits.filter((pr) => commitsMatch(commit, pr)); const isPushed = commit.pushed === true || matchingPRs.length > 0; const merged: MergedCommit = { ...commit, pushed: isPushed, statsAvailable: true, detailTarget: localCommitTarget(commit), }; (isPushed ? buckets.pushed : buckets.unpushed).push(merged); for (const pr of matchingPRs) { const key = prCommitKey(pr); buckets.matchedPRKeys.add(key); if (isPushed) buckets.localPushedByPRKey.set(key, merged); } } return buckets; } function orderSingleRepoPushedCommits( prCommits: PRCommitForMerge[], providerOnly: MergedCommit[], pushed: MergedCommit[], localPushedByPRKey: Map, ): MergedCommit[] { const ordered: MergedCommit[] = []; const added = new Set(); const providerOnlyBySHA = new Map(providerOnly.map((commit) => [commit.commit_sha, commit])); for (const pr of [...prCommits].reverse()) { const local = localPushedByPRKey.get(prCommitKey(pr)); if (local && !added.has(local)) { ordered.push(local); added.add(local); continue; } const provider = providerOnlyBySHA.get(pr.sha); if (provider && !added.has(provider)) { ordered.push(provider); added.add(provider); } } ordered.push(...pushed.filter((commit) => !added.has(commit))); return ordered; } function repositoryKey(repositoryName: string | undefined): string { return repositoryName ?? ""; } function orderPushedCommitsByRepository( prCommits: PRCommitForMerge[], providerOnly: MergedCommit[], pushed: MergedCommit[], localPushedByPRKey: Map, ): MergedCommit[] { const repositoryOrder: string[] = []; const seenRepositories = new Set(); const pushedByRepository = new Map(); const providerOnlyByRepository = new Map(); const prByRepository = new Map(); const addRepository = (key: string) => { if (seenRepositories.has(key)) return; seenRepositories.add(key); repositoryOrder.push(key); }; const addCommit = (map: Map, commit: MergedCommit) => { const key = repositoryKey(commit.repository_name); addRepository(key); const commits = map.get(key) ?? []; commits.push(commit); map.set(key, commits); }; for (const commit of pushed) addCommit(pushedByRepository, commit); for (const commit of providerOnly) addCommit(providerOnlyByRepository, commit); for (const pr of prCommits) { const key = repositoryKey(pr.repository_name); addRepository(key); const commits = prByRepository.get(key) ?? []; commits.push(pr); prByRepository.set(key, commits); } const ordered: MergedCommit[] = []; for (const key of repositoryOrder) { const repoPRCommits = prByRepository.get(key) ?? []; const repoProviderOnly = providerOnlyByRepository.get(key) ?? []; const repoPushed = pushedByRepository.get(key) ?? []; const hasLocalAnchor = repoPRCommits.some((pr) => localPushedByPRKey.has(prCommitKey(pr))); if (!hasLocalAnchor) { ordered.push(...repoPushed, ...repoProviderOnly); continue; } ordered.push( ...orderSingleRepoPushedCommits( repoPRCommits, repoProviderOnly, repoPushed, localPushedByPRKey, ), ); } return ordered; } /** * Merge local session commits and PR commits into a single list. A commit is * pushed when EITHER source confirms it: the backend's `pushed` field * (computed from the upstream tracking ref) or a SHA match in `prCommits` (PR * existence implies the commit is on the remote). The git signal fixes the * original bug — pushed commits showing as unpushed when no PR exists — while * keeping the PR signal so a commit visible only via a PR (e.g. authored by * another contributor, or a rebased SHA the local repo doesn't track yet) * still renders as pushed. PR commits not represented locally are appended * as pushed. Order: unpushed first, then pushed. */ export function mergeCommits( localCommits: LocalCommitForMerge[], prCommits: PRCommitForMerge[], ): MergedCommit[] { const { unpushed, pushed, matchedPRKeys, localPushedByPRKey } = collectLocalCommits( localCommits, prCommits, ); const providerOnly: MergedCommit[] = []; appendPROnlyCommits(prCommits, matchedPRKeys, providerOnly); return [ ...unpushed, ...orderPushedCommitsByRepository(prCommits, providerOnly, pushed, localPushedByPRKey), ]; } function mapLocalCheckoutCommit(commit: { commit_sha: string; commit_message: string; insertions: number; deletions: number; repository_name?: string; committed_at?: string; }): MergedCommit { return { ...commit, pushed: false, statsAvailable: true, detailTarget: localCommitTarget(commit), presentation: "local_checkout", }; } function mapCurrentPRCommit(commit: PRCommitForMerge): MergedCommit | null { const detailTarget = githubCommitTarget(commit); if (!detailTarget) return null; return { commit_sha: commit.sha, commit_message: commit.message, insertions: commit.additions, deletions: commit.deletions, pushed: true, statsAvailable: commit.stats_available === true, detailTarget, presentation: "current_pr", ...(commit.repository_name ? { repository_name: commit.repository_name } : {}), committed_at: commit.author_date, }; } function reverseCommitsByRepository(commits: PRCommitForMerge[]): PRCommitForMerge[] { const repositoryOrder: string[] = []; const commitsByRepository = new Map(); for (const commit of commits) { const key = `${commit.owner}/${commit.repo}`; const repositoryCommits = commitsByRepository.get(key); if (repositoryCommits) { repositoryCommits.push(commit); continue; } commitsByRepository.set(key, [commit]); repositoryOrder.push(key); } return repositoryOrder.flatMap((key) => [...(commitsByRepository.get(key) ?? [])].reverse()); } /** * Keeps provider and checkout rows independent after a proven history drift. * No SHA, message, or metadata matching occurs in this mode. */ export function separateCommitHistories( localCommits: { commit_sha: string; commit_message: string; insertions: number; deletions: number; repository_name?: string; committed_at?: string; }[], prCommits: PRCommitForMerge[], ): { providerCommits: MergedCommit[]; localCommits: MergedCommit[] } { return { providerCommits: reverseCommitsByRepository(prCommits).flatMap((commit) => { const mapped = mapCurrentPRCommit(commit); return mapped ? [mapped] : []; }), localCommits: localCommits.map(mapLocalCheckoutCommit), }; } export function getBaseBranchDisplay(baseBranch: string | undefined): string { return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; } export function isMultiRepoCommits(commits: { repository_name?: string }[]): boolean { return commits.some((c) => !!c.repository_name); } type TaskPRUrlInput = { pr_url?: string; repository_id?: string; }; /** Workspace repository id → display name for multi-repo PR/commit grouping. */ export function buildRepoNameById( reposByWorkspace: Record>, ): Record { const out: Record = {}; for (const list of Object.values(reposByWorkspace)) { for (const r of list) { out[r.id] = r.name; } } return out; } /** Merge synced TaskPR URLs and client-only pending PR URLs by repo display name. */ export function buildPrByRepoMap( taskPRs: TaskPRUrlInput[] | undefined, repoNameById: Record, pendingByRepo: Record | undefined, ): Record { const map: Record = {}; if (taskPRs) { for (const pr of taskPRs) { if (!pr.pr_url) continue; const repoKey = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; map[repoKey] = map[repoKey] ?? pr.pr_url; } } if (pendingByRepo) { for (const [repoKey, url] of Object.entries(pendingByRepo)) { if (url) map[repoKey] = map[repoKey] ?? url; } } const legacySingleRepo = taskPRs?.find((p) => p.pr_url && !p.repository_id); if (!map[""] && legacySingleRepo?.pr_url) { map[""] = legacySingleRepo.pr_url; } return map; }