From b6cd203782fca11193f3f81b44133c039ec30237 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 21 Jul 2026 17:22:48 -0500 Subject: [PATCH 01/20] Unify pull request source overview layout Render the pull request description, status actions, and reviewer summary inside one consistent overview grid instead of separate nested cards. This keeps the existing data and behavior intact while making the source header reusable across the normal code view and shared review surfaces. --- core/App.css | 31 ++++-- core/SharedWalkthroughApp.tsx | 18 ++-- core/app/components/ReviewCodeView.tsx | 100 +++++++++++++----- .../walkthrough/WalkthroughDiffSurface.tsx | 5 + 4 files changed, 106 insertions(+), 48 deletions(-) diff --git a/core/App.css b/core/App.css index 5defcff5..a3fc0d5c 100644 --- a/core/App.css +++ b/core/App.css @@ -1756,23 +1756,28 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 0 8px 8px 50px; } -.codiff-source-description-footer-row { - align-items: stretch; +.codiff-source-description-overview { + align-items: start; display: grid; - gap: 8px; - grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + min-width: 0; +} + +.codiff-source-description-overview-main, +.codiff-source-description-overview-aside { + min-width: 0; } -.codiff-source-description-footer-main, -.codiff-source-description-footer-aside { +.codiff-source-description-overview-aside { display: flex; flex-direction: column; - min-width: 0; + gap: 8px; + padding: 8px 8px 8px 0; } -.codiff-source-description-footer-main > *, -.codiff-source-description-footer-aside > * { - flex: 1; +.codiff-source-description-overview-aside > * { + min-width: 0; } .source-description-comment-anonymous + .codiff-source-description-footer { @@ -1982,10 +1987,14 @@ html[data-codiff-platform='darwin'] .sidebar { } @media (max-width: 1024px) { - .codiff-source-description-footer-row { + .codiff-source-description-overview { grid-template-columns: minmax(0, 1fr); } + .codiff-source-description-overview-aside { + padding: 0 8px 8px; + } + .pull-request-merge-controls, .pull-request-merge-actions { justify-content: flex-start; diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index c075084e..d2a0a018 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -840,20 +840,12 @@ export function ReviewSurface({ onMergePullRequest={mergePullRequest} /> ) : undefined; - const sourceDescriptionFooter = - sourceDescriptionFooterMain && sourceDescriptionFooterAside ? ( -
-
{sourceDescriptionFooterMain}
-
{sourceDescriptionFooterAside}
-
- ) : ( - (sourceDescriptionFooterMain ?? sourceDescriptionFooterAside) - ); const sourceDescription = source.type === 'pull-request' ? ( ); }; @@ -1158,7 +1151,8 @@ export function ReviewSurface({ scrollTarget={treeScrollTarget} selectedPath={visibleSelectedPath} sourceDescriptionActions={sourceDescriptionActions} - sourceDescriptionFooter={sourceDescriptionFooter} + sourceDescriptionFooter={sourceDescriptionFooterMain} + sourceDescriptionFooterAside={sourceDescriptionFooterAside} walkthroughNotes={emptyWalkthroughNotes} /> ) diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 14c53cfb..0bc31b41 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -943,6 +943,7 @@ function SourceDescriptionBody({ export function PullRequestSourceDescription({ actions, footer, + footerAside, keymap, onUpdateDescription, onUpdateTitle, @@ -951,6 +952,7 @@ export function PullRequestSourceDescription({ }: { actions?: ReactNode; footer?: ReactNode; + footerAside?: ReactNode; keymap?: CodiffKeymap; onUpdateDescription?: (body: string) => Promise | void; onUpdateTitle?: (title: string) => Promise | void; @@ -972,6 +974,25 @@ export function PullRequestSourceDescription({ const isCollapsed = (!sourceDescriptionHasBody && !canEditDescription) || collapsed; const layoutKey = `source-description-panel:${source.provider ?? ''}:${source.url}:${sourceTitle}:${sourceDescription}:${source.author?.login ?? ''}:${source.author?.avatarUrl ?? ''}:${isCollapsed ? 'collapsed' : 'open'}`; + const sourceDescriptionContent = ( + {}} + onUpdateDescription={onUpdateDescription} + onUploadDescriptionAsset={onUploadDescriptionAsset} + /> + ); + const overviewAside = + footer || footerAside ? ( + + ) : null; return (
@@ -987,17 +1008,19 @@ export function PullRequestSourceDescription({ /> {!isCollapsed ? (
- {}} - onUpdateDescription={onUpdateDescription} - onUploadDescriptionAsset={onUploadDescriptionAsset} - /> - {footer ?
{footer}
: null} + {overviewAside ? ( +
+
+ {sourceDescriptionContent} +
+ {overviewAside} +
+ ) : ( + <> + {sourceDescriptionContent} + {footer ?
{footer}
: null} + + )}
) : null}
@@ -2486,6 +2509,7 @@ export function ReviewCodeView({ source, sourceDescriptionActions, sourceDescriptionFooter, + sourceDescriptionFooterAside, supportsReviewCommentActions, theme = 'system', viewed, @@ -2546,6 +2570,7 @@ export function ReviewCodeView({ source: ReviewSource; sourceDescriptionActions?: ReactNode; sourceDescriptionFooter?: ReactNode; + sourceDescriptionFooterAside?: ReactNode; supportsReviewCommentActions: boolean; theme?: CodiffPreferences['theme']; viewed: Record; @@ -3802,20 +3827,44 @@ export function ReviewCodeView({ {!sourceDescriptionCollapsed && (sourceDescriptionHasContent || canEditSourceDescription) ? (
- - {sourceDescriptionFooter ? ( -
{sourceDescriptionFooter}
- ) : null} + {sourceDescriptionFooter || sourceDescriptionFooterAside ? ( +
+
+ +
+ +
+ ) : ( + <> + + {sourceDescriptionFooter ? ( +
{sourceDescriptionFooter}
+ ) : null} + + )}
) : null} @@ -3833,6 +3882,7 @@ export function ReviewCodeView({ sourceDescriptionAriaLabel, sourceDescriptionCollapsed, sourceDescriptionFooter, + sourceDescriptionFooterAside, sourceDescriptionHasContent, sourceDescriptionLabel, sourceTitle, diff --git a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx index 29a7b32f..b0513709 100644 --- a/core/app/components/walkthrough/WalkthroughDiffSurface.tsx +++ b/core/app/components/walkthrough/WalkthroughDiffSurface.tsx @@ -35,6 +35,7 @@ export function WalkthroughDiffSurface({ scrollTarget, sourceDescriptionActions, sourceDescriptionFooter, + sourceDescriptionFooterAside, }: { allowViewedToggle?: boolean; blocks: ReadonlyArray; @@ -44,6 +45,7 @@ export function WalkthroughDiffSurface({ scrollTarget: WalkthroughBlockScrollTarget | null; sourceDescriptionActions?: ReviewCodeViewProps['sourceDescriptionActions']; sourceDescriptionFooter?: ReviewCodeViewProps['sourceDescriptionFooter']; + sourceDescriptionFooterAside?: ReviewCodeViewProps['sourceDescriptionFooterAside']; }) { return (
@@ -62,6 +64,9 @@ export function WalkthroughDiffSurface({ showSourceDescription sourceDescriptionActions={sourceDescriptionActions ?? reviewProps.sourceDescriptionActions} sourceDescriptionFooter={sourceDescriptionFooter ?? reviewProps.sourceDescriptionFooter} + sourceDescriptionFooterAside={ + sourceDescriptionFooterAside ?? reviewProps.sourceDescriptionFooterAside + } walkthroughNotes={emptyWalkthroughNotes} />
From 44778f2d3a3879435cfb8d3d5b7fbcb9c6e0b62a Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 21 Jul 2026 17:25:01 -0500 Subject: [PATCH 02/20] Keep the walkthrough arc fixed above local content Render the walkthrough arc outside the flexing `.wt-hybrid` content container for every host, matching the existing Web presentation. This keeps chapter navigation visible while the diff body scrolls and removes the local-versus-Web placement distinction. --- .../walkthrough/NarrativeWalkthroughView.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index aad330e5..ac072a37 100644 --- a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx +++ b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx @@ -663,21 +663,26 @@ export function NarrativeWalkthroughView({ } : null; + const arc = ( + + ); + return ( -
- + <> + {arc} +
{navigation.mode === 'commit' ? ( Chapter {navigation.index + 1} - ) : null} -
+ ) : null} +
+ ); } From 6a95acf58d0c8fbc1ae766c707664e8528ed094f Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:31 -0500 Subject: [PATCH 03/20] Define provider-neutral review-history contracts Introduce forge-neutral revision, range, comparison, evolution, version, and review-plan types plus the constructors and plan-resolution helpers that operate on them. Keeping this domain model independent gives Codiff Web and provider adapters a stable contract before any shared review UI is introduced. --- core/__tests__/review-history.test.ts | 113 +++++++ core/lib/review-history.ts | 181 +++++++++++ core/types.ts | 414 +++++++++++++++++++++++++- 3 files changed, 693 insertions(+), 15 deletions(-) create mode 100644 core/__tests__/review-history.test.ts create mode 100644 core/lib/review-history.ts diff --git a/core/__tests__/review-history.test.ts b/core/__tests__/review-history.test.ts new file mode 100644 index 00000000..ab5a1b4b --- /dev/null +++ b/core/__tests__/review-history.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from 'vite-plus/test'; +import { + commitRevisionLabel, + diffComparison, + diffRange, + resolveReviewPlan, + reviewableUnits, + revisionRef, + reviewVersionOption, + versionOptionHeadCommitId, + versionRevisionLabel, +} from '../lib/review-history.ts'; +import type { ReviewEvolutionUnit } from '../types.ts'; + +const base = revisionRef('a'.repeat(40), commitRevisionLabel('base')); +const headOld = revisionRef('b'.repeat(40), versionRevisionLabel('v1')); +const headNew = revisionRef('c'.repeat(40), versionRevisionLabel('v2')); + +const before = diffRange(base, headOld); +const after = diffRange(base, headNew); +const comparison = diffComparison(before, after); + +const units: ReadonlyArray = [ + { + after: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'A', + parentIds: [base.commitId], + sha: headNew.commitId, + shortSha: headNew.commitId.slice(0, 7), + subject: 'feat: add', + }, + confidence: 'high', + id: 'introduced-1', + kind: 'introduced', + order: 0, + reviewable: true, + }, + { + after: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'A', + parentIds: [base.commitId], + sha: headOld.commitId, + shortSha: headOld.commitId.slice(0, 7), + subject: 'same', + }, + before: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'A', + parentIds: [base.commitId], + sha: headOld.commitId, + shortSha: headOld.commitId.slice(0, 7), + subject: 'same', + }, + confidence: 'exact', + id: 'retained-1', + kind: 'retained', + order: 1, + reviewable: false, + }, +]; + +test('projects version options through DiffRange identity', () => { + const version = reviewVersionOption({ + createdAt: '2026-01-02T00:00:00.000Z', + id: '2', + number: 2, + range: after, + }); + expect(versionOptionHeadCommitId(version)).toBe(headNew.commitId); + expect(version.range.base.commitId).toBe(base.commitId); +}); + +test('filters non-reviewable evolution markers from unit plans', () => { + expect(reviewableUnits(units).map((unit) => unit.kind)).toEqual(['introduced']); +}); + +test('resolves whole-diff and unit plans from recommendations', () => { + expect( + resolveReviewPlan({ + comparison, + recommendation: { + rationale: 'Low pairing confidence.', + suggestedStructure: 'whole-diff', + }, + units, + }), + ).toMatchObject({ structure: 'whole-diff' }); + + expect( + resolveReviewPlan({ + comparison, + recommendation: { + rationale: 'Review introduced units.', + suggestedStructure: 'commit-by-commit', + }, + structure: 'auto', + units, + }), + ).toMatchObject({ + structure: 'units', + units: [{ kind: 'introduced' }], + }); + + expect( + resolveReviewPlan({ + comparison, + structure: 'whole-diff', + units, + }), + ).toMatchObject({ structure: 'whole-diff' }); +}); diff --git a/core/lib/review-history.ts b/core/lib/review-history.ts new file mode 100644 index 00000000..754b74f7 --- /dev/null +++ b/core/lib/review-history.ts @@ -0,0 +1,181 @@ +import type { + DiffComparison, + DiffComparisonAnalysis, + DiffComparisonView, + DiffRange, + ReviewCommitEvolution, + ReviewEvolutionUnit, + ReviewPlan, + ReviewStructureRecommendation, + ReviewUnit, + ReviewVersionOption, + RevisionLabel, + RevisionRef, +} from '../types.ts'; + +/** Create a commit-scoped revision label. */ +export const commitRevisionLabel = (text: string, url?: string): RevisionLabel => ({ + kind: 'commit', + text, + ...(url ? { url } : {}), +}); + +/** Create a version-scoped revision label. */ +export const versionRevisionLabel = (text: string, url?: string): RevisionLabel => ({ + kind: 'version', + text, + ...(url ? { url } : {}), +}); + +export const revisionRef = ( + commitId: string, + label: RevisionLabel, + aliases?: ReadonlyArray, +): RevisionRef => ({ + commitId, + label, + ...(aliases?.length ? { aliases } : {}), +}); + +export const diffRange = (base: RevisionRef, head: RevisionRef): DiffRange => ({ base, head }); + +export const diffComparison = (before: DiffRange, after: DiffRange): DiffComparison => ({ + after, + before, +}); + +export const reviewVersionOption = ({ + createdAt, + diffStat, + id, + isHead, + number, + previousCreatedAt, + previousNumber, + range, +}: { + createdAt: string; + diffStat?: ReviewVersionOption['diffStat']; + id: string; + isHead?: boolean; + number?: number; + previousCreatedAt?: string; + previousNumber?: number; + range: DiffRange; +}): ReviewVersionOption => ({ + createdAt, + id, + range, + ...(diffStat ? { diffStat } : {}), + ...(isHead != null ? { isHead } : {}), + ...(number != null ? { number } : {}), + ...(previousCreatedAt ? { previousCreatedAt } : {}), + ...(previousNumber != null ? { previousNumber } : {}), +}); + +export const isReviewableUnit = (unit: ReviewEvolutionUnit): unit is ReviewUnit => + unit.reviewable === true; + +export const reviewableUnits = ( + units: ReadonlyArray, +): ReadonlyArray => units.filter(isReviewableUnit); + +export const resolveReviewPlan = ({ + analysis, + comparison, + recommendation, + structure, + units, +}: { + analysis?: DiffComparisonAnalysis; + comparison?: DiffComparison; + recommendation?: ReviewStructureRecommendation; + structure?: 'commit-by-commit' | 'units' | 'whole-diff' | 'auto'; + units?: ReadonlyArray; +}): ReviewPlan => { + const reviewUnits = units ? reviewableUnits(units) : []; + const resolved = + structure === 'whole-diff' + ? 'whole-diff' + : structure === 'commit-by-commit' || structure === 'units' + ? 'units' + : recommendation?.suggestedStructure === 'commit-by-commit' && reviewUnits.length > 0 + ? 'units' + : 'whole-diff'; + + if (resolved === 'units' && reviewUnits.length > 0) { + return { + structure: 'units', + units: reviewUnits, + ...(analysis ? { analysis } : {}), + ...(comparison ? { comparison } : {}), + }; + } + + return { + structure: 'whole-diff', + ...(analysis ? { analysis } : {}), + ...(comparison ? { comparison } : {}), + }; +}; + +export const diffComparisonView = ({ + analysis, + comparison, + files, + from, + to, +}: { + analysis: DiffComparisonAnalysis; + comparison: DiffComparison; + files: DiffComparisonView['files']; + from: ReviewVersionOption; + to: ReviewVersionOption; +}): DiffComparisonView => ({ + analysis, + comparison, + files, + from, + to, +}); + +export const reviewCommitEvolution = (evolution: ReviewCommitEvolution): ReviewCommitEvolution => + evolution; + +export const versionOptionHeadCommitId = (version: ReviewVersionOption) => + version.range.head.commitId; + +export const versionOptionBaseCommitId = (version: ReviewVersionOption) => + version.range.base.commitId; + +export const versionOptionLabelText = (version: ReviewVersionOption) => + version.number != null + ? version.number === 0 + ? 'Base' + : `v${version.number}` + : version.range.head.label.text; + +export const evolutionUnitCommit = (unit: ReviewEvolutionUnit) => { + if (unit.kind === 'commit') { + return unit.commit; + } + if (unit.kind === 'introduced') { + return unit.after; + } + if (unit.kind === 'removed') { + return unit.before; + } + if (unit.kind === 'revised') { + return unit.after ?? unit.before; + } + if (unit.kind === 'ambiguous') { + return unit.after ?? unit.before; + } + if (unit.kind === 'absorbed-into-base') { + return unit.baseCommit ?? unit.before ?? unit.after; + } + return unit.after ?? unit.before; +}; + +export const evolutionUnitRebaseDrivers = (unit: ReviewEvolutionUnit) => + unit.kind === 'revised' ? (unit.rebaseDrivers ?? []) : []; diff --git a/core/types.ts b/core/types.ts index 2682715f..161ccd1c 100644 --- a/core/types.ts +++ b/core/types.ts @@ -125,16 +125,8 @@ export type ReviewSource = type: 'branch-diff'; } | { - /** - * Resolved base commit for the branch part of the comparison. Optional - * because the CLI can construct this source from just a branch name - * (`codiff main`), before merge-base resolution happens; the resolved - * state's `source` always carries a concrete value. - */ baseRef?: string; - /** Resolved head commit for the branch part of the comparison. See {@link baseRef}. */ headRef?: string; - /** Target branch the current branch was compared against. */ ref: string; type: 'branch-working-tree'; } @@ -365,8 +357,6 @@ export type SharedPlanSnapshot = { version: 1; }; -export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; - export type ShareResult = | { status: 'uploaded'; @@ -378,8 +368,330 @@ export type ShareResult = }; export type SharePlanResult = ShareResult; + +export type WalkthroughShareManifestV1 = SharedWalkthroughSnapshot; + +export type ReviewPreferences = Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' +>; export type ShareWalkthroughResult = ShareResult; +/** + * Mutable display label for a revision. + * + * Labels can move or be renumbered (bookmarks, version tags). They are for + * display and navigation only and must not be treated as durable identity. + */ +export type RevisionLabel = { + kind: 'bookmark' | 'branch' | 'commit' | 'review-marker' | 'tag' | 'version'; + text: string; + /** Optional provider web URL for this label (for example a commit page). */ + url?: string; +}; + +/** + * Provider-neutral revision identity for this iteration of Codiff. + * + * `commitId` is the full Git commit SHA. Provider-specific extras such as a + * GitLab `startSha` stay inside the provider adapter and are projected into + * this shape at the host boundary. + */ +export type RevisionRef = { + /** Additional display labels that resolve to the same commit. */ + aliases?: ReadonlyArray; + /** Full Git commit SHA for this iteration. */ + commitId: string; + label: RevisionLabel; +}; + +/** + * Provider-neutral base/head review range. + * + * Contains only review semantics shared across hosts. GitLab retains its full + * `{ baseSha, startSha, headSha }` identity inside the GitLab adapter for + * historical fetches, cache keys, and comment positions. `startSha` is not + * authoring input. + */ +export type DiffRange = { + base: RevisionRef; + head: RevisionRef; +}; + +/** Identity of a before/after review comparison. */ +export type DiffComparison = { + after: DiffRange; + before: DiffRange; +}; + +export type DiffComparisonCommentAssociation = { + commentId: string; + filePath?: string; + status: 'newly-anchored' | 'outdated' | 'resolved-by-change' | 'still-valid'; +}; + +export type DiffComparisonBaseMovementCommit = { + authoredAt: string; + authorName: string; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type DiffComparisonBaseMovement = { + changed: boolean; + commits?: ReadonlyArray; + commitsBetween: number | null; + commitTimestampDeltaMs: number | null; + diffStat: { additions: number; deletions: number; filesChanged: number } | null; + from: { committedAt: string | null; sha: string; shortSha: string; webUrl?: string }; + relationship: 'forward' | 'backward' | 'divergent' | 'unknown'; + to: { committedAt: string | null; sha: string; shortSha: string; webUrl?: string }; + truncated: boolean; + warning?: string; +}; + +export type DiffComparisonSummary = { + addedLines: number; + baseMoved: boolean; + commentsAffected: number; + conflictFiles: number; + deletedLines: number; + empty: boolean; + filesChanged: number; + intentionalFiles: number; + noiseFiles: number; +}; + +export type ReviewCommitSummary = { + authoredAt: string; + authorName: string; + diffStat?: { additions: number; deletions: number; filesChanged: number }; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type ReviewRebaseDriverCommit = { + authoredAt: string; + authorName: string; + overlappingPaths: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +/** + * Discriminated reviewable work unit. + * + * Units are generation targets only. Non-reviewable stack markers such as + * retained or absorbed commits live on {@link ReviewEvolutionUnit}. + */ +export type ReviewUnit = + | { + commit: ReviewCommitSummary; + id: string; + /** Parent-to-commit range for an ordinary MR commit. */ + kind: 'commit'; + order: number; + reviewable: true; + } + | { + after: ReviewCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + /** Newly added range between comparison endpoints. */ + kind: 'introduced'; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + reviewable: true; + } + | { + before: ReviewCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + /** Reverse materialization of an earlier range removed from the stack. */ + kind: 'removed'; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + reviewable: true; + } + | { + after: ReviewCommitSummary; + before: ReviewCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + /** Comparison of earlier and later commit ranges for one logical change. */ + kind: 'revised'; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + rebaseDrivers?: ReadonlyArray; + reviewable: true; + } + | { + after?: ReviewCommitSummary; + before?: ReviewCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + /** Authoritative fallback range when pairing is uncertain. */ + kind: 'ambiguous'; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + reviewable: true; + }; + +/** Non-reviewable evolution markers retained for stack display. */ +export type ReviewEvolutionMarkerUnit = { + after?: ReviewCommitSummary; + baseCommit?: ReviewCommitSummary; + before?: ReviewCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + kind: 'retained' | 'rewritten-same-patch' | 'absorbed-into-base'; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + reviewable: false; +}; + +export type ReviewEvolutionUnit = ReviewUnit | ReviewEvolutionMarkerUnit; + +export type ReviewEvolutionSummary = { + absorbedIntoBase: number; + added: number; + ambiguous: number; + pairingCoverage: number; + removed: number; + retained: number; + reviewable: number; + revised: number; + rewrittenSamePatch: number; +}; + +/** + * Provider recommendation only. Hosts may override before resolving a + * {@link ReviewPlan}. + */ +export type ReviewStructureRecommendation = { + confidence?: number; + rationale: string; + suggestedStructure: 'commit-by-commit' | 'whole-diff'; +}; + +export type ReviewCommitEvolution = { + recommendation: ReviewStructureRecommendation; + summary: ReviewEvolutionSummary; + units: ReadonlyArray; + warnings?: ReadonlyArray; +}; + +/** + * Derived comparison data kept separate from {@link DiffComparison} identity. + */ +export type DiffComparisonAnalysis = { + baseMovement?: DiffComparisonBaseMovement; + commentAssociations?: ReadonlyArray; + commitEvolution?: ReviewCommitEvolution; + summary: DiffComparisonSummary; + warnings?: ReadonlyArray; +}; + +/** + * Resolved generation plan. Distinct from {@link ReviewStructureRecommendation}. + * + * - `whole-diff` materializes one {@link RepositoryState} + * - `units` materializes one {@link RepositoryState} per {@link ReviewUnit} + */ +export type ReviewPlan = + | { + analysis?: DiffComparisonAnalysis; + comparison?: DiffComparison; + structure: 'whole-diff'; + } + | { + analysis?: DiffComparisonAnalysis; + comparison?: DiffComparison; + structure: 'units'; + units: ReadonlyArray; + }; + +/** + * Host-facing version row for review history pickers. + * + * Durable identity is `id` plus the projected {@link DiffRange}. Provider + * internals such as GitLab `startSha` remain in the adapter. + */ +export type ReviewVersionOption = { + createdAt: string; + diffStat?: { additions: number; deletions: number; filesChanged: number }; + id: string; + isHead?: boolean; + number?: number; + previousCreatedAt?: string; + previousNumber?: number; + range: DiffRange; +}; + +/** Materialized comparison view consumed by shared review UI. */ +export type DiffComparisonView = { + analysis: DiffComparisonAnalysis; + comparison: DiffComparison; + files: ReadonlyArray; + from: ReviewVersionOption; + to: ReviewVersionOption; +}; + +export type ReviewCommitListEntry = { + authoredAt: string; + authorName: string; + role?: string; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type ReviewStrategySummary = { + confidence: number; + mode: 'commit-by-commit' | 'whole-diff'; + reason: string; +}; + +/** + * Top-level walkthrough authoring context. + * + * A range target can use a whole-diff or ordinary commit-unit plan. A + * comparison target requires comparison analysis and can use a whole-diff or + * evolution-unit plan. + */ +export type WalkthroughGenerationInput = + | { + kind: 'range'; + plan: ReviewPlan; + range: DiffRange; + /** Present for whole-diff plans and as the aggregate state for composition. */ + state: RepositoryState; + /** Present for unit plans: one materialized state per review unit. */ + unitStates?: ReadonlyArray<{ state: RepositoryState; unit: ReviewUnit }>; + } + | { + analysis: DiffComparisonAnalysis; + comparison: DiffComparison; + kind: 'comparison'; + plan: ReviewPlan; + /** Present for whole-diff comparison plans. */ + state?: RepositoryState; + unitStates?: ReadonlyArray<{ state: RepositoryState; unit: ReviewUnit }>; + }; + export type WalkthroughContext = { changedFiles?: ReadonlyArray<{ path: string; @@ -525,7 +837,19 @@ export type WalkthroughHunkGroup = { }; /** One stop in the main walkthrough path. */ +export type WalkthroughCommentReference = { + authorName: string; + body: string; + filePath: string; + id: string; + lineNumber?: number; + status: 'newly-anchored' | 'outdated' | 'resolved-by-change' | 'still-valid'; + url?: string; +}; + export type WalkthroughStop = WalkthroughHunkGroup & { + /** Review comments whose anchored code region overlaps this stop. */ + commentReferences?: ReadonlyArray; importance: 'critical' | 'normal' | 'context'; /** Agent narration (markdown / inline code). */ prose: string; @@ -541,6 +865,28 @@ export type WalkthroughSupportGroup = WalkthroughHunkGroup & { /** A named chapter in the walkthrough. */ export type WalkthroughChapter = { blurb: string; + /** Commit boundary metadata for commit-by-commit walkthroughs. */ + commit?: { + gitSha?: string; + /** + * Base-branch commits that likely forced this MR commit rewrite on rebase. + * Present for version-comparison commit-by-commit units when attribution exists. + */ + rebaseDrivers?: ReadonlyArray<{ + authoredAt?: string; + authorName?: string; + overlappingPaths?: ReadonlyArray; + sha?: string; + shortSha: string; + subject: string; + webUrl?: string; + }>; + revisionCause?: string; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }; icon: WalkthroughIcon; id: string; stops: ReadonlyArray; @@ -572,6 +918,8 @@ export type NarrativeWalkthrough = { * composer at the end of the walkthrough. Stripped unless `source` is a working tree. */ commit?: WalkthroughCommit; + /** Commit-scoped diff files used to resolve per-commit walkthrough hunk anchors. */ + commitFiles?: ReadonlyArray; /** The originating conversation, embedded for in-app Q&A. */ context?: WalkthroughContext; /** 1–2 sentence summary of the change. */ @@ -754,11 +1102,6 @@ export type CodiffPreferences = { wordWrap: boolean; }; -export type ReviewPreferences = Pick< - CodiffPreferences, - 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' ->; - export type PullRequestReviewComment = { anchor?: 'file' | 'line'; body: string; @@ -780,8 +1123,49 @@ export type PullRequestExistingReviewComment = PullRequestReviewComment & { id: string; isOutdated?: boolean; isThreadResolved?: boolean; + /** Diff identity the comment was positioned against (provider SHAs). */ + positionIdentity?: { + baseSha: string; + headSha: string; + startSha: string; + }; + resolution?: { + confidence: 'approximate' | 'exact'; + nearbyHunkContext?: { + after?: string; + before?: string; + }; + versions: ReadonlyArray<{ + fromLabel: string; + toLabel: string; + }>; + }; + submittedAt?: string; + url?: string; + versionAssociation?: 'exact' | 'unmatched'; + versionHeadSha?: string; + versionId?: string; + versionLabel?: string; +}; + +export type PullRequestAIReviewDecision = + | 'approved' + | 'approved-with-comments' + | 'changes-requested' + | 'unknown'; + +export type PullRequestAIReview = { + body: string; + decision: PullRequestAIReviewDecision; + id: string; + reviewedHeadSha?: string; + reviewer: ReviewAuthor & { id: string }; submittedAt?: string; url?: string; + versionAssociation?: 'exact' | 'unmatched'; + versionHeadSha?: string; + versionId?: string; + versionLabel?: string; }; export type PullRequestGeneralComment = { From 85420d66fef138585062e96c20e2f6da4b3af8b5 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:46 -0500 Subject: [PATCH 04/20] Add shared walkthrough authoring primitives Centralize draft parsing, prompt construction, hunk indexing, normalization, comment attachment, and unit composition in Core. This gives Codiff Web and Electron one deterministic authoring pipeline while leaving model execution, retries, caching, and storage in their respective hosts. --- core/__tests__/walkthrough-authoring.test.ts | 289 +++++ core/lib/walkthrough-authoring.ts | 1158 ++++++++++++++++++ core/package.json | 17 +- core/tsconfig.build.json | 1 + core/walkthrough-authoring.ts | 29 + 5 files changed, 1493 insertions(+), 1 deletion(-) create mode 100644 core/__tests__/walkthrough-authoring.test.ts create mode 100644 core/lib/walkthrough-authoring.ts create mode 100644 core/walkthrough-authoring.ts diff --git a/core/__tests__/walkthrough-authoring.test.ts b/core/__tests__/walkthrough-authoring.test.ts new file mode 100644 index 00000000..dd17e493 --- /dev/null +++ b/core/__tests__/walkthrough-authoring.test.ts @@ -0,0 +1,289 @@ +import { expect, test } from 'vite-plus/test'; +import { + attachVersionCommentReferences, + buildWalkthroughPrompt, + buildWalkthroughPromptInput, + composeUnitWalkthroughs, + indexWalkthroughHunks, + normalizeWalkthroughDraft, + parseWalkthroughDraft, +} from '../lib/walkthrough-authoring.ts'; +import type { RepositoryState } from '../types.ts'; + +const state = { + branch: 'feature/walkthrough', + files: [ + { + fingerprint: 'fingerprint', + path: 'src/app.ts', + sections: [ + { + binary: false, + id: 'src/app.ts:pull-request:42', + kind: 'pull-request', + patch: + 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-old();\n+newCall();\n@@ -10,0 +11 @@\n+test();\n', + }, + ], + status: 'modified', + }, + ], + generatedAt: Date.parse('2026-06-26T00:00:00.000Z'), + launchPath: 'cloudflare/voidzero/codiff', + root: 'cloudflare/voidzero/codiff', + source: { + headSha: 'head-sha', + host: 'gitlab.example.com', + number: 42, + projectPath: 'cloudflare/voidzero/codiff', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/cloudflare/voidzero/codiff/-/merge_requests/42', + }, +} satisfies RepositoryState; + +test('indexes stable hunk ids but sends compact aliases in the prompt', () => { + const index = indexWalkthroughHunks(state.files); + expect(index.hunks.map(({ id }) => id)).toEqual([ + 'src/app.ts:pull-request:42:h1', + 'src/app.ts:pull-request:42:h2', + ]); + expect(index.hunkIdByAlias.get('h1')).toBe('src/app.ts:pull-request:42:h1'); + const prompt = buildWalkthroughPrompt(state); + expect(prompt).toContain('"id":"h1"'); + expect(prompt).not.toContain('"id":"src/app.ts:pull-request:42:h1"'); + expect(prompt).toContain('compact request-local aliases'); + expect(prompt).toContain('Every stop must have a concise semantic title'); +}); + +test('includes commit-by-commit strategy guidance in the walkthrough prompt', () => { + const prompt = buildWalkthroughPrompt(state, { + reviewStrategy: { + commits: [ + { + role: 'feature', + shortSha: 'aaaaaaaa', + subject: 'Add feature', + }, + ], + confidence: 0.9, + mode: 'commit-by-commit', + reason: 'stacked-subjects', + }, + }); + expect(prompt).toContain('Review strategy is commit-by-commit'); + expect(prompt).toContain('Add feature'); +}); + +test('normalizes draft aliases back onto live hunk ids and fills support', () => { + const index = indexWalkthroughHunks(state.files); + const walkthrough = normalizeWalkthroughDraft( + { + chapters: [ + { + blurb: 'Core change', + icon: 'path', + id: 'c1', + stops: [ + { + hunkIds: ['h1'], + id: 's1', + importance: 'critical', + prose: 'Explain the new call path.', + title: 'New call path', + }, + ], + title: 'Core', + }, + ], + focus: 'Review the feature.', + kind: 'narrative', + title: 'Feature walkthrough', + version: 4, + }, + state, + 'codex', + ); + expect(walkthrough.chapters[0]?.stops[0]?.hunkIds).toEqual([index.hunks[0]!.id]); + expect(walkthrough.support.length).toBeGreaterThan(0); + expect(walkthrough.support.some((item) => item.hunkIds.includes(index.hunks[1]!.id))).toBe(true); +}); + +test('accepts compact and legacy nullable draft shapes', () => { + const compact = parseWalkthroughDraft({ + chapters: [ + { + blurb: 'Core', + icon: 'path', + id: 'c1', + stops: [ + { + hunkIds: ['h1'], + id: 's1', + importance: 'normal', + prose: 'Details', + title: 'Title here', + }, + ], + title: 'Core', + }, + ], + focus: 'Focus', + kind: 'narrative', + title: 'Title', + version: 4, + }); + expect(compact.chapters[0]?.stops[0]?.title).toBe('Title here'); + + const legacy = parseWalkthroughDraft({ + chapters: [ + { + blurb: 'Core', + icon: 'path', + id: 'c1', + stops: [ + { + changeType: null, + commitNote: null, + hunkIds: ['h1'], + id: 's1', + importance: 'normal', + notes: null, + prose: 'Details', + summary: null, + title: null, + }, + ], + title: 'Core', + }, + ], + focus: 'Focus', + kind: 'narrative', + support: null, + title: 'Title', + version: 4, + }); + expect(legacy.chapters[0]?.stops[0]?.hunkIds).toEqual(['h1']); + expect(legacy.support).toBeUndefined(); +}); + +test('attaches overlapping version comment references to stops', () => { + const index = indexWalkthroughHunks(state.files); + const walkthrough = normalizeWalkthroughDraft( + { + chapters: [ + { + blurb: 'Core change', + icon: 'path', + id: 'c1', + stops: [ + { + hunkIds: [index.hunks[0]!.id], + id: 's1', + importance: 'critical', + prose: 'Explain the new call path.', + title: 'New call path', + }, + ], + title: 'Core', + }, + ], + focus: 'Review the feature.', + kind: 'narrative', + title: 'Feature walkthrough', + version: 4, + }, + state, + 'codex', + ); + const withComments = attachVersionCommentReferences(walkthrough, [ + { + authorName: 'Ada', + body: 'Please rename this.', + filePath: 'src/app.ts', + id: 'c1', + lineNumber: 1, + status: 'still-valid', + }, + ]); + expect(withComments.chapters[0]?.stops[0]?.commentReferences?.[0]?.id).toBe('c1'); +}); + +test('includes version-commit guidance and composes unit walkthroughs', () => { + const prompt = buildWalkthroughPrompt(state, { + versionCommitContext: { + after: { shortSha: 'bbbbbbb', subject: 'Later' }, + before: { shortSha: 'aaaaaaa', subject: 'Earlier' }, + evolutionKind: 'revised', + kind: 'version-commit', + range: { fromLabel: 'v1', toLabel: 'v2' }, + unitId: 'unit-1', + }, + versionCompareRange: { + fromLabel: 'v1', + structure: 'commit-by-commit', + toLabel: 'v2', + }, + }); + expect(prompt).toContain('logical commit between v1 and v2'); + expect(prompt).toContain('Earlier'); + expect(prompt).toContain('Later'); + + const unitWalkthrough = normalizeWalkthroughDraft( + { + chapters: [ + { + blurb: 'Unit', + icon: 'path', + id: 'c1', + stops: [ + { + hunkIds: ['h1'], + id: 's1', + importance: 'normal', + prose: 'Unit prose', + title: 'Unit stop', + }, + ], + title: 'Unit', + }, + ], + focus: 'Unit focus', + kind: 'narrative', + title: 'Unit title', + version: 4, + }, + state, + 'codex', + ); + const composed = composeUnitWalkthroughs({ + agent: 'codex', + entries: [ + { + context: { + after: { + sha: 'b'.repeat(40), + shortSha: 'bbbbbbb', + subject: 'Later', + }, + kind: 'version-commit', + range: { fromLabel: 'v1', toLabel: 'v2' }, + unitId: 'unit-1', + }, + state, + walkthrough: unitWalkthrough, + }, + ], + state, + }); + expect(composed.chapters[0]?.id.startsWith('unit-1:')).toBe(true); + expect(composed.commitFiles?.length).toBe(1); + expect(composed.title).toContain('v1'); +}); + +test('exposes prompt digest sizing and patch budgets', () => { + const { digest, patchBudgets, size } = buildWalkthroughPromptInput(state); + expect(size.hunkCount).toBe(2); + expect(digest.files[0]?.sections[0]?.hunks[0]?.id).toBe('h1'); + expect(patchBudgets.total).toBeGreaterThan(0); +}); diff --git a/core/lib/walkthrough-authoring.ts b/core/lib/walkthrough-authoring.ts new file mode 100644 index 00000000..aac60902 --- /dev/null +++ b/core/lib/walkthrough-authoring.ts @@ -0,0 +1,1158 @@ +/** + * Deterministic walkthrough authoring: draft parsing, hunk indexing, prompt + * construction, normalization, and unit composition. + * + * Hosts own model IDs, retries, and cache policy. This module owns shared + * authoring semantics used by local Codiff and Codiff Web. + */ +import { + array, + boolean, + type InferOutput, + literal, + looseObject, + maxLength, + maxValue, + minLength, + minValue, + null_, + number, + object, + optional, + parse, + picklist, + pipe, + safeParse, + string, + union, +} from 'valibot'; +import { + getSectionWalkthroughHunks, + isGeneratedWalkthroughPath, +} from './narrative-walkthrough-diff.js'; +import type { + ChangedFile, + DiffSection, + NarrativeWalkthrough, + RepositoryState, + WalkthroughCommentReference, + WalkthroughHunk, +} from '../types.ts'; + + +export const maxProseChars = 4000; +export const maxPatchExcerpt = 2500; +export const maxTotalPatchExcerpt = 60_000; +export const maxLargePatchExcerpt = 700; +export const maxLargeTotalPatchExcerpt = 35_000; +export const maxWalkthroughChapters = 20; +export const maxWalkthroughStops = 14; +export const maxHunksPerGroup = 14; + +const boundedString = (maximum: number) => pipe(string(), maxLength(maximum)); +const nonEmptyString = (maximum: number) => pipe(string(), minLength(1), maxLength(maximum)); +const positiveInt = pipe(number(), minValue(1), maxValue(1_000_000_000)); + +const noteSchema = object({ + body: nonEmptyString(500), + hunkId: nonEmptyString(200), +}); + +const reviewCommentSchema = looseObject({ + anchor: optional(picklist(['file', 'line'])), + author: looseObject({ + avatarUrl: optional(string()), + login: nonEmptyString(200), + url: optional(string()), + }), + body: nonEmptyString(maxProseChars * 8), + filePath: nonEmptyString(4096), + id: nonEmptyString(200), + isOutdated: optional(boolean()), + lineNumber: optional(positiveInt), + side: optional(picklist(['additions', 'deletions'])), + startLineNumber: optional(positiveInt), + startSide: optional(picklist(['additions', 'deletions'])), + submittedAt: optional(string()), + url: optional(string()), +}); + +const diffSectionSchema = looseObject({ + binary: boolean(), + id: nonEmptyString(500), + kind: picklist(['commit', 'pull-request', 'staged', 'unstaged']), + patch: string(), +}); + +const changedFileSchema = looseObject({ + fingerprint: nonEmptyString(256), + oldPath: optional(string()), + path: nonEmptyString(4096), + sections: array(diffSectionSchema), + status: picklist(['added', 'deleted', 'modified', 'renamed', 'untracked']), +}); + +const pullRequestSourceSchema = looseObject({ + type: literal('pull-request'), + url: nonEmptyString(2048), +}); + +const genericSourceSchema = looseObject({ + type: picklist([ + 'working-tree', + 'commit', + 'branch', + 'branch-diff', + 'branch-working-tree', + 'range', + 'pull-request', + ]), +}); + +export const repositoryStateSchema = looseObject({ + branch: union([string(), null_()]), + files: array(changedFileSchema), + generatedAt: number(), + launchPath: string(), + reviewComments: optional(array(reviewCommentSchema)), + root: string(), + source: union([pullRequestSourceSchema, genericSourceSchema]), +}); + +const changeTypeSchema = picklist([ + 'fix', + 'feature', + 'refactor', + 'test', + 'generated', + 'lockfile', + 'snapshot', + 'i18n', + 'docs', +]); + +const groupFields = { + changeType: optional(changeTypeSchema), + commitNote: optional(boundedString(1000)), + hunkIds: pipe(array(nonEmptyString(200)), minLength(1), maxLength(maxHunksPerGroup)), + id: nonEmptyString(100), + notes: optional(pipe(array(noteSchema), maxLength(maxHunksPerGroup))), + summary: optional(boundedString(1000)), + title: optional(boundedString(200)), +}; + +const stopSchema = object({ + ...groupFields, + importance: picklist(['critical', 'normal', 'context']), + prose: nonEmptyString(4000), +}); + +const supportSchema = object({ + ...groupFields, + note: optional(boundedString(2000)), + reason: nonEmptyString(200), +}); + +export const walkthroughDraftSchema = object({ + chapters: pipe( + array( + object({ + blurb: boundedString(1000), + icon: picklist(['bug', 'wrench', 'path', 'flask', 'beaker', 'doc', 'gear']), + id: nonEmptyString(100), + stops: pipe(array(stopSchema), maxLength(maxWalkthroughStops)), + title: nonEmptyString(16), + }), + ), + minLength(1), + maxLength(maxWalkthroughChapters), + ), + focus: nonEmptyString(2000), + kind: literal('narrative'), + support: optional(pipe(array(supportSchema), maxLength(30))), + title: nonEmptyString(200), + version: literal(4), +}); + +export type WalkthroughDraft = InferOutput; + +/** @deprecated Prefer WalkthroughDraft. */ +export type AuthoredWalkthrough = WalkthroughDraft; + +const nullableGroupFields = { + changeType: optional(union([changeTypeSchema, null_()])), + commitNote: optional(union([boundedString(1000), null_()])), + hunkIds: groupFields.hunkIds, + id: groupFields.id, + notes: optional(union([pipe(array(noteSchema), maxLength(maxHunksPerGroup)), null_()])), + summary: optional(union([boundedString(1000), null_()])), + title: optional(union([boundedString(200), null_()])), +}; + +const legacyWalkthroughDraftSchema = object({ + chapters: pipe( + array( + object({ + blurb: boundedString(1000), + icon: picklist(['bug', 'wrench', 'path', 'flask', 'beaker', 'doc', 'gear']), + id: nonEmptyString(100), + stops: pipe( + array( + object({ + ...nullableGroupFields, + importance: picklist(['critical', 'normal', 'context']), + prose: nonEmptyString(4000), + }), + ), + maxLength(maxWalkthroughStops), + ), + title: nonEmptyString(16), + }), + ), + minLength(1), + maxLength(maxWalkthroughChapters), + ), + focus: nonEmptyString(2000), + kind: literal('narrative'), + support: optional( + union([ + pipe( + array( + object({ + ...nullableGroupFields, + note: optional(union([boundedString(2000), null_()])), + reason: nonEmptyString(200), + }), + ), + maxLength(30), + ), + null_(), + ]), + ), + title: nonEmptyString(200), + version: literal(4), +}); + +const compactWalkthroughDraftSchema = object({ + chapters: pipe( + array( + object({ + blurb: boundedString(1000), + icon: picklist(['bug', 'wrench', 'path', 'flask', 'beaker', 'doc', 'gear']), + id: nonEmptyString(100), + stops: pipe( + array( + object({ + hunkIds: groupFields.hunkIds, + id: groupFields.id, + importance: picklist(['critical', 'normal', 'context']), + prose: nonEmptyString(4000), + title: nonEmptyString(80), + }), + ), + maxLength(maxWalkthroughStops), + ), + title: nonEmptyString(16), + }), + ), + minLength(1), + maxLength(maxWalkthroughChapters), + ), + focus: nonEmptyString(2000), + kind: literal('narrative'), + title: nonEmptyString(200), + version: literal(4), +}); + +/** Agent structured-output schema shape for hosts that need a JSON schema later. */ +export const walkthroughDraftAgentOutputSchema = compactWalkthroughDraftSchema; + +const compactNullableGroup = (group: { + changeType?: string | null; + commitNote?: string | null; + hunkIds: ReadonlyArray; + id: string; + notes?: ReadonlyArray<{ body: string; hunkId: string }> | null; + summary?: string | null; + title?: string | null; +}) => ({ + ...(group.changeType + ? { changeType: group.changeType as NonNullable } + : {}), + ...(group.commitNote ? { commitNote: group.commitNote } : {}), + hunkIds: [...group.hunkIds], + id: group.id, + ...(group.notes ? { notes: [...group.notes] } : {}), + ...(group.summary ? { summary: group.summary } : {}), + ...(group.title ? { title: group.title } : {}), +}); + +export const parseWalkthroughDraft = (value: unknown): WalkthroughDraft => { + const authored = safeParse(walkthroughDraftSchema, value); + if (authored.success) { + return authored.output; + } + + const legacyOutput = safeParse(legacyWalkthroughDraftSchema, value); + if (legacyOutput.success) { + return { + chapters: legacyOutput.output.chapters.map((chapter) => ({ + blurb: chapter.blurb, + icon: chapter.icon, + id: chapter.id, + stops: chapter.stops.map((stop) => ({ + ...compactNullableGroup(stop), + importance: stop.importance, + prose: stop.prose, + })), + title: chapter.title, + })), + focus: legacyOutput.output.focus, + kind: legacyOutput.output.kind, + ...(Array.isArray(legacyOutput.output.support) + ? { + support: legacyOutput.output.support.map((item) => ({ + ...compactNullableGroup(item), + ...(item.note ? { note: item.note } : {}), + reason: item.reason, + })), + } + : {}), + title: legacyOutput.output.title, + version: legacyOutput.output.version, + }; + } + + const output = parse(compactWalkthroughDraftSchema, value); + return { + chapters: output.chapters.map((chapter) => ({ + blurb: chapter.blurb, + icon: chapter.icon, + id: chapter.id, + stops: chapter.stops.map((stop) => ({ + hunkIds: [...stop.hunkIds], + id: stop.id, + importance: stop.importance, + prose: stop.prose, + title: stop.title, + })), + title: chapter.title, + })), + focus: output.focus, + kind: output.kind, + title: output.title, + version: output.version, + }; +}; + +/** @deprecated Prefer parseWalkthroughDraft. */ +export const parseAuthoredWalkthrough = parseWalkthroughDraft; + +export const parseRepositoryState = (value: unknown): RepositoryState => + parse(repositoryStateSchema, value) as RepositoryState; + +export type WalkthroughReviewStrategy = + | { + commits: ReadonlyArray<{ + role: string; + shortSha: string; + subject: string; + }>; + confidence: number; + mode: 'commit-by-commit'; + reason: string; + } + | { + confidence: number; + mode: 'whole-diff' | 'whole-mr'; + reason: string; + }; + + +type IndexedHunk = WalkthroughHunk & { + sectionId: string; + sectionKind: 'pull-request'; +}; + +type SectionWalkthroughHunk = ReturnType[number]; + +const defaultSideForStatus = (status: ChangedFile['status']) => + status === 'added' || status === 'untracked' + ? ('additions' as const) + : status === 'deleted' + ? ('deletions' as const) + : ('both' as const); + +const createIndexedHunk = ( + file: ChangedFile, + section: DiffSection, + hunk: SectionWalkthroughHunk, +): IndexedHunk => { + const patchHunk = 'additionStart' in hunk ? hunk : null; + const startLine = patchHunk + ? patchHunk.added > 0 + ? patchHunk.additionStart + : patchHunk.deletionStart + : undefined; + const endLine = patchHunk + ? patchHunk.added > 0 + ? patchHunk.additionEnd + : patchHunk.deletionEnd + : undefined; + const display = + startLine == null + ? file.path + : startLine === endLine + ? `${file.path}:${startLine}` + : `${file.path}:${startLine}-${endLine}`; + + return { + added: hunk.added, + ...(patchHunk ? { additionEnd: patchHunk.additionEnd } : {}), + ...(patchHunk ? { additionStart: patchHunk.additionStart } : {}), + anchor: { + display, + ...(endLine != null ? { endLine } : {}), + sectionId: section.id, + sectionKind: section.kind, + side: defaultSideForStatus(file.status), + ...(startLine != null ? { startLine } : {}), + }, + deleted: hunk.deleted, + ...(patchHunk ? { deletionEnd: patchHunk.deletionEnd } : {}), + ...(patchHunk ? { deletionStart: patchHunk.deletionStart } : {}), + id: hunk.id, + kind: patchHunk ? 'patch' : 'synthetic', + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + path: file.path, + sectionId: section.id, + sectionKind: 'pull-request', + status: file.status, + }; +}; + +const indexFileHunks = (file: ChangedFile): Array => + file.sections.flatMap((section) => + getSectionWalkthroughHunks(file, section).map((hunk) => createIndexedHunk(file, section, hunk)), + ); + +export const indexWalkthroughHunks = (files: ReadonlyArray) => { + const hunks = files.flatMap(indexFileHunks); + const byId = new Map(); + const aliasByHunkId = new Map(); + const hunkIdByAlias = new Map(); + hunks.forEach((hunk, index) => { + const alias = `h${index + 1}`; + byId.set(hunk.id, hunk); + byId.set(alias, hunk); + aliasByHunkId.set(hunk.id, alias); + hunkIdByAlias.set(alias, hunk.id); + }); + return { + aliasByHunkId, + byId, + hunkIdByAlias, + hunks, + }; +}; + +const lineCounts = (hunks: ReadonlyArray) => + hunks.reduce( + (total, hunk) => ({ + added: total.added + hunk.added, + deleted: total.deleted + hunk.deleted, + }), + { added: 0, deleted: 0 }, + ); + +const clean = (value: string, fallback = '') => value.trim() || fallback; + +export const normalizeWalkthroughDraft = ( + value: unknown, + state: RepositoryState, + agent: NarrativeWalkthrough['agent'], +): NarrativeWalkthrough => { + const authored = parseWalkthroughDraft(value); + const index = indexWalkthroughHunks(state.files); + const used = new Set(); + const itemIds = new Set(); + + const resolveGroup = (group: WalkthroughDraft["chapters"][number]["stops"][number] | NonNullable[number]) => { + if (itemIds.has(group.id)) { + return null; + } + const groupHunkIds = new Set(); + const hunks = group.hunkIds.flatMap((id) => { + const hunk = index.byId.get(id); + if (!hunk || used.has(hunk.id) || groupHunkIds.has(hunk.id)) { + return []; + } + groupHunkIds.add(hunk.id); + return [hunk]; + }); + if (hunks.length === 0) { + return null; + } + hunks.forEach((hunk) => used.add(hunk.id)); + itemIds.add(group.id); + const counts = lineCounts(hunks); + const hunkIds = hunks.map((hunk) => hunk.id); + return { + ...counts, + ...(group.changeType + ? { changeType: group.changeType as NonNullable } + : {}), + ...(group.commitNote ? { commitNote: clean(group.commitNote) } : {}), + hunkIds, + hunks, + id: group.id, + ...(group.notes + ? { + notes: group.notes + .map((note) => ({ + ...note, + hunkId: index.byId.get(note.hunkId)?.id ?? note.hunkId, + })) + .filter( + (note, noteIndex, notes) => + hunkIds.includes(note.hunkId) && + notes.findIndex((candidate) => candidate.hunkId === note.hunkId) === noteIndex, + ), + } + : {}), + ...(group.summary ? { summary: clean(group.summary) } : {}), + ...(group.title ? { title: clean(group.title) } : {}), + }; + }; + + const chapters = authored.chapters.flatMap((chapter) => { + const stops = chapter.stops.flatMap((stop) => { + const group = resolveGroup(stop); + return group + ? [ + { + ...group, + importance: stop.importance, + prose: clean(stop.prose), + }, + ] + : []; + }); + return stops.length > 0 + ? [ + { + blurb: clean(chapter.blurb), + icon: chapter.icon, + id: chapter.id, + stops, + title: clean(chapter.title, 'Review'), + }, + ] + : []; + }); + if (chapters.length === 0) { + throw new Error('The generated walkthrough did not reference any current diff hunks.'); + } + + const support = (authored.support ?? []).flatMap((item) => { + const group = resolveGroup(item); + return group + ? [ + { + ...group, + ...(item.note ? { note: clean(item.note) } : {}), + reason: clean(item.reason, 'Other changes'), + }, + ] + : []; + }); + const remainingByPath = new Map>(); + for (const hunk of index.hunks) { + if (!used.has(hunk.id)) { + remainingByPath.set(hunk.path, [...(remainingByPath.get(hunk.path) ?? []), hunk]); + } + } + for (const [path, hunks] of remainingByPath) { + for (let start = 0; start < hunks.length; start += maxHunksPerGroup) { + const chunk = hunks.slice(start, start + maxHunksPerGroup); + const counts = lineCounts(chunk); + let supportId = `support-${support.length + 1}`; + while (itemIds.has(supportId)) { + supportId = `support-${Number(supportId.slice('support-'.length)) + 1}`; + } + support.push({ + ...counts, + hunkIds: chunk.map((hunk) => hunk.id), + hunks: chunk, + id: supportId, + reason: 'Other changes', + title: path, + }); + itemIds.add(supportId); + } + } + const stopCount = chapters.reduce((count, chapter) => count + chapter.stops.length, 0); + return { + agent, + chapters, + focus: clean(authored.focus, 'Walk through the merge request.'), + generatedAt: new Date().toISOString(), + kind: 'narrative', + meta: `${stopCount} stops · ${chapters.length} chapters`, + repo: { + branch: state.branch, + root: state.root, + }, + source: state.source, + support, + title: clean(authored.title, 'Merge request walkthrough'), + version: 4, + }; +}; + +const truncate = (value: string, maxLength: number) => + value.length <= maxLength + ? value + : maxLength <= 1 + ? value.slice(0, maxLength) + : `${value.slice(0, maxLength - 1)}…`; + +const getPromptPatchBudgets = (fileCount: number) => + fileCount > 32 + ? { section: maxLargePatchExcerpt, total: maxLargeTotalPatchExcerpt } + : { section: maxPatchExcerpt, total: maxTotalPatchExcerpt }; + +const buildPatchExcerpt = ( + section: DiffSection, + remainingBudget: number, + sectionBudget: number, +) => { + const summary = section.summary?.reason ? `Summary: ${section.summary.reason}\n` : ''; + const patch = `${summary}${section.patch || ''}`; + if (!patch) { + return '[patch omitted: no text patch available]'; + } + const maxLength = Math.max(0, Math.min(sectionBudget, remainingBudget)); + return truncate(patch, maxLength); +}; + +const formatPromptLineRange = (start: number, end: number) => + start === end ? `${start}` : `${start}-${end}`; + +const buildPromptHunk = (hunk: IndexedHunk, id: string) => + hunk.kind === 'synthetic' + ? { + added: hunk.added, + deleted: hunk.deleted, + id, + kind: 'synthetic' as const, + } + : { + added: hunk.added, + deleted: hunk.deleted, + id, + kind: 'patch' as const, + newLines: formatPromptLineRange(hunk.additionStart ?? 0, hunk.additionEnd ?? 0), + oldLines: formatPromptLineRange(hunk.deletionStart ?? 0, hunk.deletionEnd ?? 0), + }; + +type WalkthroughSize = { + fileCount: number; + hunkCount: number; +}; + +const buildWalkthroughSizingGuidance = ( + { fileCount, hunkCount }: WalkthroughSize, + { independentCommit = false }: { independentCommit?: boolean } = {}, +) => { + const targetStops = + fileCount <= 2 + ? hunkCount <= 4 + ? '1-2' + : '2-3' + : fileCount <= 4 && hunkCount <= 4 + ? '1-2' + : fileCount <= 4 && hunkCount <= 8 + ? '1-3' + : fileCount <= 16 + ? '5-9' + : '6-9'; + const targetChapters = fileCount <= 2 ? '1' : fileCount <= 4 && hunkCount <= 8 ? '1-2' : '2-6'; + + return `Coverage contract: +- The digest has ${fileCount} files and ${hunkCount} reviewable hunks. Put only the highest-leverage review path in chapters[]; Codiff preserves everything else as support. +- Digest hunk ids are compact request-local aliases like h1 and h2. Return those aliases exactly; Codiff maps them back to stable live-diff ids. +- Define chapters[] and stops[] in display order. Use stable stop ids like s1, s2, and never invent hunk ids. +${ + independentCommit + ? '- Choose as many conceptual chapters as this commit needs. Do not collapse distinct review ideas into one chapter.' + : `- Target ${targetStops} main-path stops and at most ${maxWalkthroughStops}. Use ${targetChapters} conceptual chapters.` +} +- Chapter titles render in a compact top bar: keep each title to 1-2 short words and at most 16 characters. +- Default to one review idea per stop. Group multiple hunkIds when they implement the same invariant, behavior, or repeated pattern. +- Every stop must have a concise semantic title that names the review idea in roughly 2-6 words, e.g. "Prevent duplicate payments" or "Preserve offline drafts". Never use a filename or path as a stop title. +- A stop may contain at most ${maxHunksPerGroup} hunkIds, listed in the exact display order Codiff should render. +- Generated-like files have "generated":true and one synthetic hunk per changed section. Never split them; main-path them only when they explain behavior. +- Leave secondary, mechanical, docs-only, generated, styling, fixture, lockfile, snapshot, and repeated-pattern hunks out of chapters[]. Codiff automatically places every unreferenced hunk in support. +- Do not provide support, added/deleted counts, status, paths, section ids, repo, source, generatedAt, agent, meta, notes, changeType, summary, or commitNote for stops; Codiff computes display metadata from the live diff.`; +}; + +// Prompt shaping for commit-aware / version-comparison walkthroughs. +// Options flow: fate generateWalkthrough → walkthrough-agent.generate → buildWalkthroughPrompt. +export type WalkthroughPromptOptions = { + /** When set, the digest contains exactly one commit and is authored independently. */ + commitContext?: { + sha: string; + subject: string; + } | null; + reviewStrategy?: WalkthroughReviewStrategy | null; + versionBaseContext?: { + absorbedCommits: ReadonlyArray<{ + baseShortSha?: string; + shortSha: string; + subject: string; + }>; + commits: ReadonlyArray<{ shortSha: string; subject: string }>; + relationship: 'forward' | 'backward' | 'divergent' | 'unknown'; + } | null; + versionCommentReferences?: ReadonlyArray | null; + /** One changed logical commit unit inside a selected version range. */ + versionCommitContext?: { + after?: { shortSha: string; subject: string }; + before?: { shortSha: string; subject: string }; + evolutionKind: 'likely-revised' | 'added' | 'removed' | 'ambiguous'; + kind: 'version-commit'; + range: { fromLabel: string; toLabel: string }; + rebaseDrivers?: ReadonlyArray<{ + authorName: string; + overlappingPaths: ReadonlyArray; + shortSha: string; + subject: string; + }>; + unitId: string; + } | null; + /** When set, author a walkthrough of MR version evolution, not the whole net MR. */ + versionCompareRange?: { + fromLabel: string; + structure?: 'commit-by-commit' | 'whole-diff'; + toLabel: string; + } | null; +}; + +const commentTouchesHunk = (comment: WalkthroughCommentReference, hunk: WalkthroughHunk) => { + if (comment.filePath !== hunk.path && comment.filePath !== hunk.oldPath) { + return false; + } + if (comment.lineNumber == null) { + return true; + } + const line = comment.lineNumber; + return ( + (hunk.additionStart != null && + hunk.additionEnd != null && + line >= hunk.additionStart - 2 && + line <= hunk.additionEnd + 2) || + (hunk.deletionStart != null && + hunk.deletionEnd != null && + line >= hunk.deletionStart - 2 && + line <= hunk.deletionEnd + 2) + ); +}; + +export const attachVersionCommentReferences = ( + walkthrough: NarrativeWalkthrough, + comments: ReadonlyArray | null | undefined, +): NarrativeWalkthrough => + !comments?.length + ? walkthrough + : { + ...walkthrough, + chapters: walkthrough.chapters.map((chapter) => ({ + ...chapter, + stops: chapter.stops.map((stop) => { + const commentReferences = comments.filter((comment) => + stop.hunks.some((hunk) => commentTouchesHunk(comment, hunk)), + ); + return commentReferences.length ? { ...stop, commentReferences } : stop; + }), + })), + }; + +const buildReviewStrategyDigest = (strategy: WalkthroughReviewStrategy | null | undefined) => { + if (!strategy) { + return null; + } + if (strategy.mode === 'commit-by-commit') { + return { + commits: strategy.commits.map((commit) => ({ + role: commit.role, + sha: commit.shortSha, + subject: truncate(commit.subject, 120), + })), + confidence: strategy.confidence, + mode: strategy.mode, + reason: strategy.reason, + }; + } + return { + confidence: strategy.confidence, + mode: strategy.mode === 'whole-mr' ? 'whole-diff' : strategy.mode, + reason: strategy.reason, + }; +}; + +const buildCommitStructureGuidance = (strategy: WalkthroughReviewStrategy | null | undefined) => { + if (!strategy || strategy.mode !== 'commit-by-commit') { + return `- Prefer conceptual chapters across the net merge-request diff. +- A commit list may be present as weak author history context; do not structure the walkthrough around fixups or review-response commits. +- Do not invent commit metadata that is not in the digest.`; + } + return `- Review strategy is commit-by-commit (${strategy.reason}). +- Preserve distinct review ideas as separate chapters; there is no one-chapter-per-commit limit. +- Stop titles must stay semantic, but may reference the related commit subject. +- Digest hunks still come from the live whole-MR diff for stable anchors; optionally mention commit subjects in prose when they clarify chapter boundaries. +- Include every non-merge commit in its own boundary; do not group commits.`; +}; + +const buildVersionCompareStructureGuidance = ( + versionCompareRange: WalkthroughPromptOptions['versionCompareRange'], +) => { + if (!versionCompareRange) { + return ''; + } + const structureLabel = + versionCompareRange.structure === 'commit-by-commit' + ? 'commit-by-commit' + : versionCompareRange.structure === 'whole-diff' + ? 'whole-diff' + : null; + return `- This walkthrough covers the version comparison from ${versionCompareRange.fromLabel} to ${versionCompareRange.toLabel}${structureLabel ? ` as a ${structureLabel} walkthrough` : ''}. +- Focus on how the merge request itself evolved: intentional edits, conflict-resolution fallout, and newly added/removed behavior. +- Do not narrate pure rebase noise. Prefer chapters that answer "what changed since the earlier version?" for a returning reviewer. +- Hunks still come from the supplied digest; treat them as the version-comparison surface, not the full historical MR net diff.${ + structureLabel === 'commit-by-commit' + ? '\n- This request is one unit inside a commit-by-commit version walkthrough; stay scoped to the supplied unit.' + : structureLabel === 'whole-diff' + ? '\n- This is a whole-diff version walkthrough across the intentional version-comparison surface.' + : '' + }`; +}; + +const buildVersionBaseGuidance = (context: WalkthroughPromptOptions['versionBaseContext']) => { + if (!context) { + return ''; + } + const absorbed = context.absorbedCommits + .map( + (commit) => + ` - ${commit.shortSha}: ${commit.subject}${commit.baseShortSha ? ` (now in base as ${commit.baseShortSha})` : ''}`, + ) + .join('\n'); + const baseCommits = context.commits + .slice(0, 12) + .map((commit) => ` - ${commit.shortSha}: ${commit.subject}`) + .join('\n'); + return `- The target base changed between these versions (${context.relationship}). +${ + absorbed + ? `- These earlier MR commits are now supplied by the target base. Do not describe their behavior as removed: +${absorbed}` + : '- No earlier MR commits were confidently identified as moving into the target base.' +} +- Base commits are context only. Mention them only when the supplied version diff demonstrates an adaptation: +${baseCommits || ' - none available'}`; +}; + +const buildVersionCommentGuidance = ( + comments: WalkthroughPromptOptions['versionCommentReferences'], +) => { + if (!comments?.length) { + return ''; + } + return `- The digest includes review comments anchored to this version comparison. +- Mention a comment when its code region is part of the reviewer path. +- Say a comment was addressed only when its status is resolved-by-change; otherwise describe it as related context.`; +}; + +const buildSingleCommitGuidance = (commit: WalkthroughPromptOptions['commitContext']) => { + if (!commit) { + return ''; + } + return `- This is an independent walkthrough for commit ${commit.sha}: ${commit.subject}. +- Explain only the changes introduced by this commit. Do not summarize the merge request as a whole. +- Never refer to earlier or later commits, the commit stack, a rebase, or cumulative merge-request history. +- Do not infer behavior from code outside this commit's supplied diff. +- Build the best reviewer path through this commit's own diff.`; +}; + +const buildVersionCommitGuidance = (context: WalkthroughPromptOptions['versionCommitContext']) => { + if (!context) { + return ''; + } + const before = context.before ? `${context.before.shortSha}: ${context.before.subject}` : 'none'; + const after = context.after ? `${context.after.shortSha}: ${context.after.subject}` : 'none'; + if (context.evolutionKind === 'added') { + return `- This is one commit added between ${context.range.fromLabel} and ${context.range.toLabel}: ${after}. +- Explain only this new commit's own contribution. Do not summarize the complete version range.`; + } + if (context.evolutionKind === 'removed') { + return `- This commit was removed from the MR stack between ${context.range.fromLabel} and ${context.range.toLabel}: ${before}. +- The supplied diff is intentionally inverted. Explain what was removed from the MR, never as newly authored implementation.`; + } + if (context.evolutionKind === 'ambiguous') { + return `- GitLab commit changes could not be paired confidently between ${context.range.fromLabel} and ${context.range.toLabel}. +- This fallback contains authoritative whole-range effects not safely attributable to one logical commit. +- Explain these as other stack changes and do not invent commit identity.`; + } + const drivers = context.rebaseDrivers ?? []; + if (drivers.length === 0) { + return `- This is the change to one logical commit between ${context.range.fromLabel} and ${context.range.toLabel}. +- Earlier commit: ${before}. Later commit: ${after}. +- Explain only the supplied patch evolution, not the complete later commit or changes in other commits. +- This pairing is heuristic. Never claim the two SHAs are exactly the same commit. +- If the patch looks like conflict resolution or context-line churn, say the rewrite may be rebase fallout, but only when the supplied hunks support that reading. +- Do not invent base-branch commits that are not listed in the digest. +- Treat the supplied hunks as the source of truth.`; + } + const primary = drivers[0]!; + const driverLines = drivers + .map( + (driver) => + ` - ${driver.shortSha}: ${driver.subject} (by ${driver.authorName}; overlaps ${driver.overlappingPaths.slice(0, 4).join(', ')}${driver.overlappingPaths.length > 4 ? ', …' : ''})`, + ) + .join('\n'); + return `- This is a REBASE-REVISED logical commit between ${context.range.fromLabel} and ${context.range.toLabel}. +- Earlier commit: ${before}. Later commit: ${after}. +- Opening requirement: the first sentence of the first chapter blurb MUST start from the base-branch update that this rebase pulled in, using this shape: + "Because this rebase brought in ${primary.shortSha} (${primary.subject}) from the base branch, this MR commit was revised to …" +- Preferred primary driver: ${primary.shortSha} — ${primary.subject}. +- All attributed base-branch commits brought in by the rebase: +${driverLines} +- After that opening sentence, explain only the supplied patch evolution for this logical commit. +- Distinguish clearly: + 1) adaptations required because the rebase moved the MR onto newer base-branch commits, versus + 2) intentional new MR behavior not explained by those base-branch commits. +- Do not narrate the entire base branch; only the listed base commits that the rebase introduced under this MR commit. +- Prefer wording like "brought in by the rebase", "now present on the updated base", or "required after rebasing onto newer base commits". Avoid "landed by rebase". +- This pairing is heuristic. Never claim the two SHAs are exactly the same commit. +- Treat the supplied hunks as the source of truth. +`; +}; + +export const buildWalkthroughPromptInput = ( + state: RepositoryState, + options: WalkthroughPromptOptions = {}, +) => { + const index = indexWalkthroughHunks(state.files); + const patchBudgets = getPromptPatchBudgets(state.files.length); + const size = { + fileCount: state.files.length, + hunkCount: index.hunks.length, + }; + let remainingPatchBudget = patchBudgets.total; + const digest = { + branch: state.branch, + commitContext: options.commitContext ?? null, + files: state.files.map((file) => ({ + ...(isGeneratedWalkthroughPath(file.path) + ? { + generated: true, + generatedReason: + 'Generated-like file; Codiff exposes each changed section as one synthetic hunk.', + } + : {}), + oldPath: file.oldPath, + path: file.path, + sections: file.sections.map((section) => { + const patchExcerpt = buildPatchExcerpt(section, remainingPatchBudget, patchBudgets.section); + remainingPatchBudget = Math.max(0, remainingPatchBudget - patchExcerpt.length); + return { + binary: section.binary, + hunks: index.hunks + .filter((hunk) => hunk.sectionId === section.id) + .map((hunk) => buildPromptHunk(hunk, index.aliasByHunkId.get(hunk.id) ?? hunk.id)), + id: section.id, + kind: section.kind, + loadState: section.loadState, + patchExcerpt, + summary: section.summary?.reason, + }; + }), + status: file.status, + })), + reviewStrategy: buildReviewStrategyDigest(options.reviewStrategy), + source: + (options.commitContext || options.versionCommitContext) && + state.source.type === 'pull-request' + ? { + ...state.source, + description: undefined, + title: + options.commitContext?.subject ?? + options.versionCommitContext?.after?.subject ?? + options.versionCommitContext?.before?.subject ?? + state.source.title, + } + : state.source.type === 'pull-request' && typeof state.source.description === 'string' + ? { ...state.source, description: truncate(state.source.description, maxProseChars) } + : state.source, + versionBaseContext: options.versionBaseContext ?? null, + versionCommentReferences: (options.versionCommentReferences ?? []).map((comment) => ({ + ...comment, + body: truncate(comment.body, 500), + })), + versionCommitContext: options.versionCommitContext ?? null, + versionCompareRange: options.versionCompareRange ?? null, + }; + return { digest, patchBudgets, size }; +}; + +export const buildWalkthroughPrompt = ( + state: RepositoryState, + options: WalkthroughPromptOptions = {}, +) => { + const { digest, size } = buildWalkthroughPromptInput(state, options); + return `Author a Codiff narrative walkthrough for this GitLab merge request. + +Use only the supplied digest. Return the required structured object. If source.description is present, treat it as author-written intent and orientation, not proof of behavior; patches and hunk data remain the source of truth. + +${buildWalkthroughSizingGuidance(size, { independentCommit: Boolean(options.commitContext || options.versionCommitContext) })} + +Product rules: +- Write all user-visible text in English. +- Order the review by conceptual leverage, not file path. +- Keep prose concise, concrete, and evidence-based. Inline code is allowed, but no headings or lists. +- Do not claim tests, risks, or behavior that the diff does not support. +${buildCommitStructureGuidance(options.reviewStrategy)} +${buildVersionCompareStructureGuidance(options.versionCompareRange)} +${buildVersionBaseGuidance(options.versionBaseContext)} +${buildSingleCommitGuidance(options.commitContext)} +${buildVersionCommitGuidance(options.versionCommitContext)} +${buildVersionCommentGuidance(options.versionCommentReferences)} + +Repository digest: +${JSON.stringify(digest)}`; +}; + + + +/** @deprecated Prefer normalizeWalkthroughDraft. */ +export const normalizeAuthoredWalkthrough = normalizeWalkthroughDraft; + +export type UnitWalkthroughEntry = { + context: + | { + kind: 'mr-commit'; + commit: { + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }; + } + | { + kind: 'version-commit'; + after?: { + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }; + before?: { + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }; + commentReferences?: ReadonlyArray; + range: { fromLabel: string; toLabel: string }; + rebaseDrivers?: ReadonlyArray<{ + authoredAt?: string; + authorName?: string; + overlappingPaths?: ReadonlyArray; + sha?: string; + shortSha: string; + subject: string; + webUrl?: string; + }>; + unitId: string; + }; + state: RepositoryState; + walkthrough: NarrativeWalkthrough | null; +}; + +/** + * Deterministically compose completed unit walkthroughs into one narrative. + */ +export const composeUnitWalkthroughs = ({ + agent, + entries, + state, +}: { + agent: NarrativeWalkthrough['agent']; + entries: ReadonlyArray; + state: RepositoryState; +}): NarrativeWalkthrough => { + const chapters = entries.flatMap((entry) => { + const context = entry.context; + const identity = context.kind === 'mr-commit' ? context.commit.sha : context.unitId; + const summary = + context.kind === 'mr-commit' ? context.commit : (context.after ?? context.before); + const entryWalkthrough = + context.kind === 'version-commit' && entry.walkthrough + ? attachVersionCommentReferences(entry.walkthrough, context.commentReferences) + : entry.walkthrough; + const rebaseDrivers = context.kind === 'version-commit' ? (context.rebaseDrivers ?? []) : []; + return (entryWalkthrough?.chapters ?? []).map((chapter, chapterIndex) => ({ + ...chapter, + ...(chapterIndex === 0 && summary + ? { + commit: { + gitSha: summary.sha, + sha: identity, + shortSha: summary.shortSha, + subject: summary.subject, + webUrl: summary.webUrl, + ...(rebaseDrivers.length > 0 + ? { + rebaseDrivers: rebaseDrivers.map((driver) => ({ + authoredAt: driver.authoredAt, + authorName: driver.authorName, + overlappingPaths: driver.overlappingPaths, + sha: driver.sha, + shortSha: driver.shortSha, + subject: driver.subject, + webUrl: driver.webUrl, + })), + } + : {}), + }, + } + : {}), + id: `${identity}:${chapter.id}`, + stops: chapter.stops.map((stop) => ({ ...stop, id: `${identity}:${stop.id}` })), + })); + }); + const versionContext = entries.find((entry) => entry.context.kind === 'version-commit')?.context; + const isVersionComparison = versionContext?.kind === 'version-commit'; + return { + agent, + chapters, + commitFiles: entries.flatMap((entry) => entry.state.files), + focus: isVersionComparison + ? `Review ${entries.length} changed commit units in stack order.` + : `Review ${entries.length} commits in topological order, from oldest to newest.`, + generatedAt: new Date().toISOString(), + kind: 'narrative', + repo: { branch: state.branch, root: state.root }, + source: state.source, + support: [], + title: isVersionComparison + ? `Commit-by-commit changes from ${versionContext.range.fromLabel} to ${versionContext.range.toLabel}` + : 'Commit walkthroughs', + version: 4, + }; +}; + +/** @deprecated Prefer composeUnitWalkthroughs. */ +export const combineCommitWalkthroughs = composeUnitWalkthroughs; diff --git a/core/package.json b/core/package.json index e9113900..84ff0e6d 100644 --- a/core/package.json +++ b/core/package.json @@ -47,10 +47,25 @@ "types": "./dist/lib/narrative-walkthrough-diff.d.ts", "@nkzw/codiff-source": "./lib/narrative-walkthrough-diff.js", "default": "./dist/lib/narrative-walkthrough-diff.mjs" + }, + "./types": { + "types": "./dist/types.d.ts", + "@nkzw/codiff-source": "./types.ts", + "default": "./dist/types.d.ts" + }, + "./narrative-walkthrough-diff": { + "types": "./dist/lib/narrative-walkthrough-diff.d.ts", + "@nkzw/codiff-source": "./lib/narrative-walkthrough-diff.js", + "default": "./dist/lib/narrative-walkthrough-diff.mjs" + }, + "./walkthrough-authoring": { + "types": "./dist/walkthrough-authoring.d.ts", + "@nkzw/codiff-source": "./walkthrough-authoring.ts", + "default": "./dist/walkthrough-authoring.mjs" } }, "scripts": { - "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" + "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts react.ts share.ts walkthrough-authoring.ts lib/narrative-walkthrough-diff.js App.css && vp exec tsc -p tsconfig.build.json" }, "dependencies": { "@nkzw/mdx-editor": "^0.2.2", diff --git a/core/tsconfig.build.json b/core/tsconfig.build.json index 9ed75c10..c4db1239 100644 --- a/core/tsconfig.build.json +++ b/core/tsconfig.build.json @@ -16,6 +16,7 @@ "index.ts", "react.ts", "share.ts", + "walkthrough-authoring.ts", "types.ts", "SharedPlanApp.tsx", "SharedWalkthroughApp.tsx", diff --git a/core/walkthrough-authoring.ts b/core/walkthrough-authoring.ts new file mode 100644 index 00000000..e97c4876 --- /dev/null +++ b/core/walkthrough-authoring.ts @@ -0,0 +1,29 @@ +export { + attachVersionCommentReferences, + buildWalkthroughPrompt, + buildWalkthroughPromptInput, + combineCommitWalkthroughs, + composeUnitWalkthroughs, + indexWalkthroughHunks, + maxHunksPerGroup, + maxLargePatchExcerpt, + maxLargeTotalPatchExcerpt, + maxPatchExcerpt, + maxProseChars, + maxTotalPatchExcerpt, + maxWalkthroughChapters, + maxWalkthroughStops, + normalizeAuthoredWalkthrough, + normalizeWalkthroughDraft, + parseAuthoredWalkthrough, + parseRepositoryState, + parseWalkthroughDraft, + repositoryStateSchema, + walkthroughDraftAgentOutputSchema, + walkthroughDraftSchema, + type AuthoredWalkthrough, + type UnitWalkthroughEntry, + type WalkthroughDraft, + type WalkthroughPromptOptions, + type WalkthroughReviewStrategy, +} from './lib/walkthrough-authoring.ts'; From c4ea0164ece46510318ed5897322c9ee82cd8b7f Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:48 -0500 Subject: [PATCH 05/20] Add forge-neutral commit stack matching Implement patch-signature generation and confidence-based pairing for commits across rewritten review stacks. The matcher lives in Core so GitLab and GitHub can share retained, revised, removed, ambiguous, and absorbed-into-base classification without duplicating provider logic. --- core/lib/commit-stack-evolution.ts | 830 +++++++++++++++++++++++++++++ core/lib/crypto.ts | 5 + 2 files changed, 835 insertions(+) create mode 100644 core/lib/commit-stack-evolution.ts create mode 100644 core/lib/crypto.ts diff --git a/core/lib/commit-stack-evolution.ts b/core/lib/commit-stack-evolution.ts new file mode 100644 index 00000000..f2376202 --- /dev/null +++ b/core/lib/commit-stack-evolution.ts @@ -0,0 +1,830 @@ +/** + * Forge-neutral commit-stack evolution: patch signatures + stack matching. + * + * Used by GitLab MR versions and GitHub force-push head comparisons so both + * forges share one pairing algorithm. + */ +import type { ChangedFile } from '../types.ts'; +import { sha256 } from './crypto.ts'; + +export const versionCommitSignatureAlgorithmVersion = 'patch-signature-v1'; +export const versionCommitEvolutionAlgorithmVersion = 'monotonic-v1'; +export const versionCommitStackLimit = 40; +export const versionCommitDiffConcurrency = 4; + +export type VersionCommitMatchKind = + | 'retained' + | 'rewritten-same-patch' + | 'likely-revised' + | 'absorbed-into-base' + | 'added' + | 'removed' + | 'ambiguous'; + +export type VersionCommitSummary = { + authoredAt: string; + authorName: string; + diffStat?: { + additions: number; + deletions: number; + filesChanged: number; + }; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl: string; +}; + +export type CommitPatchSignature = { + additions: number; + changedPaths: ReadonlyArray; + changeTokenSketch: ReadonlyArray; + commitSha: string; + deletions: number; + exactPatchId: string; + filesChanged: number; + subjectKey: string; +}; + +export type VersionRebaseDriverCommit = { + authoredAt: string; + authorName: string; + /** Paths this base commit shares with the revised MR commit interdiff. */ + overlappingPaths: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl: string; +}; + +export type VersionCommitEvolutionUnit = { + after?: VersionCommitSummary; + baseCommit?: VersionCommitSummary; + before?: VersionCommitSummary; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + kind: VersionCommitMatchKind; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + /** + * Base-branch commits that likely forced this commit rewrite during rebase. + * Present only for revised units when base movement can be attributed. + */ + rebaseDrivers?: ReadonlyArray; + reviewable: boolean; +}; + +/** Minimal endpoint identity needed for evolution range bookkeeping. */ +export type DiffEndpointRef = { + baseSha: string; + headSha: string; + id?: string; + label?: string; + startSha?: string; + createdAt?: string; +}; + +export type CommitStackEvolutionRange = { + from: DiffEndpointRef; + to: DiffEndpointRef; + paths?: ReadonlyArray; +}; + +export type CommitStackEvolution = { + range: CommitStackEvolutionRange; + recommendation: { + reason: string; + structure: 'commit-by-commit' | 'whole-diff'; + }; + summary: { + absorbedIntoBase: number; + added: number; + ambiguous: number; + pairingCoverage: number; + removed: number; + retained: number; + reviewable: number; + revised: number; + rewrittenSamePatch: number; + }; + units: ReadonlyArray; + warnings?: ReadonlyArray; +}; + +/** @deprecated Prefer CommitStackEvolution. */ +export type MergeRequestVersionCommitEvolution = CommitStackEvolution; + +type CommitLike = { + authoredDate: string; + authorName: string; + message: string; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + title: string; + webUrl: string; +}; + +const normalizedSubject = (value: string) => + value + .toLowerCase() + .replaceAll(/^(?:fixup!|squash!|amend!)\s*/g, '') + .replaceAll(/[^a-z0-9]+/g, ' ') + .trim(); + +const lineCount = (files: ReadonlyArray) => { + let additions = 0; + let deletions = 0; + for (const file of files) { + for (const section of file.sections) { + for (const line of section.patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } + if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + } + } + return { additions, deletions }; +}; + +const normalizePatch = (files: ReadonlyArray) => + files + .map((file) => { + const changedLines = file.sections + .flatMap((section) => section.patch.split('\n')) + .filter( + (line) => + (line.startsWith('+') || line.startsWith('-')) && + !line.startsWith('+++') && + !line.startsWith('---'), + ) + .map((line) => `${line[0]}${line.slice(1).trim().replaceAll(/\s+/g, ' ')}`); + return [file.oldPath ?? file.path, file.path, file.status, ...changedLines].join('\n'); + }) + .toSorted() + .join('\n--file--\n'); + +const sketchHash = (value: string) => { + let hash = 2_166_136_261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +export const createCommitPatchSignature = async ( + commit: Pick, + files: ReadonlyArray, +): Promise => { + const { additions, deletions } = lineCount(files); + const tokens = files + .flatMap((file) => file.sections) + .flatMap((section) => section.patch.split('\n')) + .filter( + (line) => + (line.startsWith('+') || line.startsWith('-')) && + !line.startsWith('+++') && + !line.startsWith('---'), + ) + .flatMap( + (line) => + line + .slice(1) + .toLowerCase() + .match(/[a-z_][a-z0-9_]*|\d+|[^\s\w]/g) ?? [], + ) + .map(sketchHash); + return { + additions, + changedPaths: [ + ...new Set(files.flatMap((file) => [file.oldPath, file.path]).filter(Boolean)), + ].toSorted() as Array, + changeTokenSketch: [...new Set(tokens)].toSorted().slice(0, 128), + commitSha: commit.sha, + deletions, + exactPatchId: await sha256(normalizePatch(files)), + filesChanged: files.length, + subjectKey: normalizedSubject(commit.title), + }; +}; + +export const toVersionCommitSummary = ( + commit: CommitLike, + signature?: CommitPatchSignature, +): VersionCommitSummary => + ({ + authoredAt: commit.authoredDate, + authorName: commit.authorName, + ...(signature + ? { + diffStat: { + additions: signature.additions, + deletions: signature.deletions, + filesChanged: signature.filesChanged, + }, + } + : {}), + parentIds: commit.parentIds, + sha: commit.sha, + shortSha: commit.shortSha || commit.sha.slice(0, 8), + subject: commit.title || commit.message.split('\n')[0] || 'Commit', + webUrl: commit.webUrl, + }) as VersionCommitSummary; + +const jaccard = (first: ReadonlyArray, second: ReadonlyArray) => { + const left = new Set(first); + const right = new Set(second); + const union = new Set([...left, ...right]); + if (union.size === 0) { + return 1; + } + let intersection = 0; + for (const value of left) { + if (right.has(value)) { + intersection += 1; + } + } + return intersection / union.size; +}; + +const sizeSimilarity = (first: CommitPatchSignature, second: CommitPatchSignature) => { + const left = first.additions + first.deletions; + const right = second.additions + second.deletions; + return Math.max(left, right) === 0 ? 1 : Math.min(left, right) / Math.max(left, right); +}; + +const scoreCandidate = ( + oldCommit: CommitLike, + newCommit: CommitLike, + oldSignature: CommitPatchSignature, + newSignature: CommitPatchSignature, + oldPosition: number, + newPosition: number, + oldLength: number, + newLength: number, +) => { + const subject = jaccard(oldSignature.subjectKey.split(' '), newSignature.subjectKey.split(' ')); + const author = oldCommit.authorName.toLowerCase() === newCommit.authorName.toLowerCase() ? 1 : 0; + const paths = jaccard(oldSignature.changedPaths, newSignature.changedPaths); + const tokens = jaccard(oldSignature.changeTokenSketch, newSignature.changeTokenSketch); + const size = sizeSimilarity(oldSignature, newSignature); + const relativePosition = + 1 - Math.min(1, Math.abs(oldPosition / oldLength - newPosition / newLength)); + const score = + subject * 0.22 + + author * 0.08 + + paths * 0.24 + + tokens * 0.32 + + size * 0.1 + + relativePosition * 0.04; + const reasons = [ + subject >= 0.7 ? 'similar subject' : null, + author ? 'same author' : null, + paths >= 0.6 ? 'overlapping paths' : null, + tokens >= 0.6 ? 'similar changed lines' : null, + size >= 0.7 ? 'similar patch size' : null, + ].filter((reason): reason is string => reason != null); + return { reasons, score }; +}; + +const unitId = async ( + range: CommitStackEvolutionRange, + kind: VersionCommitMatchKind, + before?: string, + after?: string, +) => + `vcu-${( + await sha256( + [ + range.from.baseSha, + range.from.headSha, + range.to.baseSha, + range.to.headSha, + kind, + before, + after, + ].join(':'), + ) + ).slice(0, 20)}`; + +export const recommendVersionWalkthroughStructure = ({ + ambiguous, + failedUnitDiffs = 0, + pairingCoverage, + reviewable, + tinyUnits = 0, +}: { + ambiguous: number; + failedUnitDiffs?: number; + pairingCoverage: number; + reviewable: number; + tinyUnits?: number; +}): CommitStackEvolution['recommendation'] => { + if (reviewable <= 1) { + return { + reason: 'A whole diff is clearer for zero or one changed commit.', + structure: 'whole-diff', + }; + } + if (failedUnitDiffs > 0) { + return { reason: 'Some commit-unit diffs could not be materialized.', structure: 'whole-diff' }; + } + if (pairingCoverage < 0.8) { + return { reason: 'Commit pairing confidence is below 80%.', structure: 'whole-diff' }; + } + if (ambiguous > Math.max(1, Math.floor(reviewable * 0.2))) { + return { reason: 'Too many stack changes are ambiguous.', structure: 'whole-diff' }; + } + if (tinyUnits > reviewable / 2) { + return { + reason: 'The stack is dominated by tiny mechanical changes.', + structure: 'whole-diff', + }; + } + return { + reason: `Review ${reviewable} changed commit units in stack order.`, + structure: 'commit-by-commit', + }; +}; + +export const matchVersionCommitStacks = async ({ + baseCommits = [], + baseStackComplete = true, + from, + newCommits, + oldCommits, + signatures, + stackCompleteness = { new: true, old: true }, + to, + warnings = [], +}: { + baseCommits?: ReadonlyArray; + baseStackComplete?: boolean; + from: DiffEndpointRef; + newCommits: ReadonlyArray; + oldCommits: ReadonlyArray; + signatures: ReadonlyMap; + stackCompleteness?: { new: boolean; old: boolean }; + to: DiffEndpointRef; + warnings?: ReadonlyArray; +}): Promise => { + const range = { from, to }; + const matches: Array<{ + after?: CommitLike; + baseCommit?: CommitLike; + before?: CommitLike; + confidence: VersionCommitEvolutionUnit['confidence']; + kind: VersionCommitMatchKind; + reasons?: ReadonlyArray; + score?: number; + }> = []; + const usedOld = new Set(); + const usedNew = new Set(); + const usedBase = new Set(); + const newBySha = new Map(newCommits.map((commit) => [commit.sha, commit])); + + for (const commit of oldCommits) { + const after = newBySha.get(commit.sha); + if (!after) { + continue; + } + usedOld.add(commit.sha); + usedNew.add(after.sha); + matches.push({ after, before: commit, confidence: 'exact', kind: 'retained' }); + } + + const oldByPatch = new Map>(); + const newByPatch = new Map>(); + const ambiguousOld = new Set(); + const ambiguousNew = new Set(); + for (const commit of oldCommits.filter((entry) => !usedOld.has(entry.sha))) { + const signature = signatures.get(commit.sha); + if (signature) { + oldByPatch.set(signature.exactPatchId, [ + ...(oldByPatch.get(signature.exactPatchId) ?? []), + commit, + ]); + } + } + for (const commit of newCommits.filter((entry) => !usedNew.has(entry.sha))) { + const signature = signatures.get(commit.sha); + if (signature) { + newByPatch.set(signature.exactPatchId, [ + ...(newByPatch.get(signature.exactPatchId) ?? []), + commit, + ]); + } + } + for (const [patchId, oldMatches] of oldByPatch) { + const newMatches = newByPatch.get(patchId) ?? []; + if (newMatches.length > 0 && (oldMatches.length > 1 || newMatches.length > 1)) { + oldMatches.forEach((commit) => ambiguousOld.add(commit.sha)); + newMatches.forEach((commit) => ambiguousNew.add(commit.sha)); + continue; + } + if (oldMatches.length !== 1 || newMatches.length !== 1) { + continue; + } + const before = oldMatches[0]!; + const after = newMatches[0]!; + usedOld.add(before.sha); + usedNew.add(after.sha); + matches.push({ after, before, confidence: 'exact', kind: 'rewritten-same-patch' }); + } + + const exactAnchors = matches + .filter((match) => match.before && match.after) + .map((match) => ({ + newIndex: newCommits.findIndex((commit) => commit.sha === match.after!.sha), + oldIndex: oldCommits.findIndex((commit) => commit.sha === match.before!.sha), + })); + const respectsExactAnchors = (oldIndex: number, newIndex: number) => + exactAnchors.every( + (anchor) => + (oldIndex < anchor.oldIndex && newIndex < anchor.newIndex) || + (oldIndex > anchor.oldIndex && newIndex > anchor.newIndex), + ); + + let lastNewIndex = -1; + for (let oldIndex = 0; oldIndex < oldCommits.length; oldIndex += 1) { + const before = oldCommits[oldIndex]!; + if (usedOld.has(before.sha) || ambiguousOld.has(before.sha)) { + continue; + } + const oldSignature = signatures.get(before.sha); + if (!oldSignature || before.parentIds.length > 1) { + continue; + } + const candidates = newCommits + .map((after, newIndex) => ({ after, newIndex })) + .filter( + ({ after, newIndex }) => + !usedNew.has(after.sha) && + !ambiguousNew.has(after.sha) && + newIndex > lastNewIndex && + respectsExactAnchors(oldIndex, newIndex) && + after.parentIds.length <= 1, + ) + .map(({ after, newIndex }) => { + const signature = signatures.get(after.sha); + return signature + ? { + after, + newIndex, + ...scoreCandidate( + before, + after, + oldSignature, + signature, + oldIndex, + newIndex, + Math.max(1, oldCommits.length - 1), + Math.max(1, newCommits.length - 1), + ), + } + : null; + }) + .filter((candidate): candidate is NonNullable => candidate != null) + .toSorted((first, second) => second.score - first.score); + const best = candidates[0]; + const runnerUp = candidates[1]; + if (!best || best.score < 0.72 || (runnerUp && best.score - runnerUp.score < 0.12)) { + if (best && best.score >= 0.55) { + ambiguousOld.add(before.sha); + ambiguousNew.add(best.after.sha); + } + continue; + } + usedOld.add(before.sha); + usedNew.add(best.after.sha); + lastNewIndex = best.newIndex; + matches.push({ + after: best.after, + before, + confidence: 'high', + kind: 'likely-revised', + reasons: best.reasons, + score: Number(best.score.toFixed(3)), + }); + } + + const unmatchedOld = () => oldCommits.filter((commit) => !usedOld.has(commit.sha)); + const baseBySha = new Map(baseCommits.map((commit) => [commit.sha, commit])); + for (const before of unmatchedOld()) { + const baseCommit = baseBySha.get(before.sha); + if (!baseCommit || usedBase.has(baseCommit.sha)) { + continue; + } + usedOld.add(before.sha); + usedBase.add(baseCommit.sha); + matches.push({ + baseCommit, + before, + confidence: 'exact', + kind: 'absorbed-into-base', + reasons: ['Commit is now present in the later target base'], + }); + } + + const baseByPatch = new Map>(); + for (const commit of baseCommits.filter((entry) => !usedBase.has(entry.sha))) { + const signature = signatures.get(commit.sha); + if (signature) { + baseByPatch.set(signature.exactPatchId, [ + ...(baseByPatch.get(signature.exactPatchId) ?? []), + commit, + ]); + } + } + for (const before of unmatchedOld()) { + const signature = signatures.get(before.sha); + if (!signature) { + continue; + } + const candidates = baseByPatch + .get(signature.exactPatchId) + ?.filter((commit) => !usedBase.has(commit.sha)); + if (candidates?.length !== 1) { + continue; + } + const baseCommit = candidates[0]!; + usedOld.add(before.sha); + usedBase.add(baseCommit.sha); + matches.push({ + baseCommit, + before, + confidence: 'exact', + kind: 'absorbed-into-base', + reasons: ['Equivalent patch is now present in the later target base'], + }); + } + + for (let oldIndex = 0; oldIndex < oldCommits.length; oldIndex += 1) { + const before = oldCommits[oldIndex]!; + if (usedOld.has(before.sha) || ambiguousOld.has(before.sha)) { + continue; + } + const oldSignature = signatures.get(before.sha); + if (!oldSignature || before.parentIds.length > 1) { + continue; + } + const candidates = baseCommits + .map((baseCommit, baseIndex) => ({ baseCommit, baseIndex })) + .filter(({ baseCommit }) => !usedBase.has(baseCommit.sha) && baseCommit.parentIds.length <= 1) + .map(({ baseCommit, baseIndex }) => { + const signature = signatures.get(baseCommit.sha); + return signature + ? { + baseCommit, + ...scoreCandidate( + before, + baseCommit, + oldSignature, + signature, + oldIndex, + baseIndex, + Math.max(1, oldCommits.length - 1), + Math.max(1, baseCommits.length - 1), + ), + } + : null; + }) + .filter((candidate): candidate is NonNullable => candidate != null) + .toSorted((first, second) => second.score - first.score); + const best = candidates[0]; + const runnerUp = candidates[1]; + if (!best || best.score < 0.72 || (runnerUp && best.score - runnerUp.score < 0.12)) { + continue; + } + usedOld.add(before.sha); + usedBase.add(best.baseCommit.sha); + matches.push({ + baseCommit: best.baseCommit, + before, + confidence: 'high', + kind: 'absorbed-into-base', + reasons: [...best.reasons, 'Logical commit is now present in the later target base'], + score: Number(best.score.toFixed(3)), + }); + } + + const canClassifyUnpaired = stackCompleteness.old && stackCompleteness.new; + const canClassifyRemoved = canClassifyUnpaired && baseStackComplete; + for (const commit of oldCommits) { + if (!usedOld.has(commit.sha)) { + const classificationIsIncomplete = + !canClassifyRemoved || !signatures.has(commit.sha) || ambiguousOld.has(commit.sha); + matches.push({ + before: commit, + confidence: 'unmatched', + kind: classificationIsIncomplete ? 'ambiguous' : 'removed', + ...(classificationIsIncomplete + ? { + reasons: ['Insufficient evidence to classify this commit as removed'], + } + : {}), + }); + } + } + for (const commit of newCommits) { + if (!usedNew.has(commit.sha)) { + const classificationIsIncomplete = + !canClassifyUnpaired || !signatures.has(commit.sha) || ambiguousNew.has(commit.sha); + matches.push({ + after: commit, + confidence: 'unmatched', + kind: classificationIsIncomplete ? 'ambiguous' : 'added', + ...(classificationIsIncomplete + ? { + reasons: ['Insufficient evidence to classify this commit as new'], + } + : {}), + }); + } + } + + const oldOrder = new Map(oldCommits.map((commit, index) => [commit.sha, index])); + const newOrder = new Map(newCommits.map((commit, index) => [commit.sha, index])); + const units = await Promise.all( + matches.map(async (match) => { + const beforeSignature = match.before ? signatures.get(match.before.sha) : undefined; + const afterSignature = match.after ? signatures.get(match.after.sha) : undefined; + const baseSignature = match.baseCommit ? signatures.get(match.baseCommit.sha) : undefined; + const oldIndex = match.before + ? (oldOrder.get(match.before.sha) ?? oldCommits.length) + : oldCommits.length; + const nextMatched = match.baseCommit + ? matches + .filter( + (candidate) => + candidate.before && + candidate.after && + (oldOrder.get(candidate.before.sha) ?? oldCommits.length) > oldIndex, + ) + .toSorted( + (first, second) => + (oldOrder.get(first.before!.sha) ?? oldCommits.length) - + (oldOrder.get(second.before!.sha) ?? oldCommits.length), + )[0] + : undefined; + const order = match.after + ? (newOrder.get(match.after.sha) ?? newCommits.length) + : match.baseCommit && nextMatched?.after + ? (newOrder.get(nextMatched.after.sha) ?? 0) - + (oldOrder.get(nextMatched.before!.sha)! - oldIndex) / (oldCommits.length + 1) + : newCommits.length + oldIndex / (oldCommits.length + 1); + return { + ...(match.after ? { after: toVersionCommitSummary(match.after, afterSignature) } : {}), + ...(match.baseCommit + ? { baseCommit: toVersionCommitSummary(match.baseCommit, baseSignature) } + : {}), + ...(match.before ? { before: toVersionCommitSummary(match.before, beforeSignature) } : {}), + confidence: match.confidence, + id: await unitId( + range, + match.kind, + match.before?.sha, + match.after?.sha ?? match.baseCommit?.sha, + ), + kind: match.kind, + ...(match.reasons ? { matchReasons: match.reasons } : {}), + ...(match.score != null ? { matchScore: match.score } : {}), + order, + reviewable: + (match.kind === 'likely-revised' || match.kind === 'added' || match.kind === 'removed') && + (match.before?.parentIds.length ?? 0) <= 1 && + (match.after?.parentIds.length ?? 0) <= 1, + } satisfies VersionCommitEvolutionUnit; + }), + ); + units.sort((first, second) => first.order - second.order || first.id.localeCompare(second.id)); + const count = (kind: VersionCommitMatchKind) => units.filter((unit) => unit.kind === kind).length; + const revised = count('likely-revised'); + const absorbedIntoBase = count('absorbed-into-base'); + const removed = count('removed'); + const added = count('added'); + const ambiguous = count('ambiguous'); + const pairingDenominator = revised + Math.min(removed, added); + const pairingCoverage = pairingDenominator === 0 ? 1 : revised / pairingDenominator; + const reviewable = units.filter((unit) => unit.reviewable).length; + return { + range, + recommendation: recommendVersionWalkthroughStructure({ + ambiguous, + pairingCoverage, + reviewable, + }), + summary: { + absorbedIntoBase, + added, + ambiguous, + pairingCoverage, + removed, + retained: count('retained'), + reviewable, + revised, + rewrittenSamePatch: count('rewritten-same-patch'), + }, + units, + ...(warnings.length ? { warnings } : {}), + }; +}; + +const pathTokens = (paths: ReadonlyArray) => + paths + .flatMap((path) => path.toLowerCase().split(/[^a-z0-9]+/g)) + .filter((token) => token.length > 2); + +export const scoreBaseCommitAsRebaseDriver = ({ + baseSignature, + unitSignature, +}: { + baseSignature: Pick< + CommitPatchSignature, + 'changedPaths' | 'changeTokenSketch' | 'additions' | 'deletions' + >; + unitSignature: Pick< + CommitPatchSignature, + 'changedPaths' | 'changeTokenSketch' | 'additions' | 'deletions' + >; +}) => { + const overlappingPaths = unitSignature.changedPaths.filter((path) => + baseSignature.changedPaths.includes(path), + ); + const pathOverlap = + unitSignature.changedPaths.length === 0 + ? 0 + : overlappingPaths.length / unitSignature.changedPaths.length; + const tokens = jaccard(unitSignature.changeTokenSketch, baseSignature.changeTokenSketch); + const pathNameTokens = jaccard( + pathTokens(unitSignature.changedPaths), + pathTokens(baseSignature.changedPaths), + ); + const left = unitSignature.additions + unitSignature.deletions; + const right = baseSignature.additions + baseSignature.deletions; + const size = Math.max(left, right) === 0 ? 1 : Math.min(left, right) / Math.max(left, right); + // Prefer path overlap heavily: a base commit that touches the same files is the + // strongest signal that the MR commit was revised due to rebase conflict fallout. + const score = pathOverlap * 0.62 + tokens * 0.28 + pathNameTokens * 0.06 + size * 0.04; + return { + overlappingPaths, + score: Number(score.toFixed(3)), + }; +}; + +export const attributeRebaseDrivers = ({ + baseCommits, + baseSignatures, + limit = 3, + unitSignature, +}: { + baseCommits: ReadonlyArray<{ + authoredAt: string; + authorName: string; + sha: string; + shortSha: string; + subject: string; + webUrl: string; + }>; + baseSignatures: ReadonlyMap; + limit?: number; + unitSignature: CommitPatchSignature | null | undefined; +}): Array => { + if (!unitSignature || unitSignature.changedPaths.length === 0 || baseCommits.length === 0) { + return []; + } + return baseCommits + .map((commit) => { + const signature = baseSignatures.get(commit.sha); + if (!signature) { + return null; + } + const { overlappingPaths, score } = scoreBaseCommitAsRebaseDriver({ + baseSignature: signature, + unitSignature, + }); + if (score < 0.22 || overlappingPaths.length === 0) { + return null; + } + return { + authoredAt: commit.authoredAt, + authorName: commit.authorName, + overlappingPaths, + score, + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + webUrl: commit.webUrl, + }; + }) + .filter((entry): entry is NonNullable => entry != null) + .toSorted((first, second) => second.score - first.score || first.sha.localeCompare(second.sha)) + .slice(0, limit) + .map(({ score: _score, ...commit }) => commit); +}; diff --git a/core/lib/crypto.ts b/core/lib/crypto.ts new file mode 100644 index 00000000..c0e06c34 --- /dev/null +++ b/core/lib/crypto.ts @@ -0,0 +1,5 @@ +const bytesToHex = (buffer: ArrayBuffer) => + [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + +export const sha256 = async (value: string) => + bytesToHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); From e60b4c25e562f3d20945cc17cb21f958d2d09500 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:48 -0500 Subject: [PATCH 06/20] Add GitLab review-history transport and analysis Introduce the GitLab transport abstraction and read-side provider implementation for versions, commit diffs, comparisons, review strategy, and commit evolution. Build the provider on Core review-history contracts and the shared matcher so Codiff Web can supply authenticated HTTP transport without carrying provider logic in its host. --- core/index.ts | 86 + gitlab/__tests__/history-transport.test.ts | 113 ++ gitlab/package.json | 36 + gitlab/src/crypto.ts | 5 + gitlab/src/history.ts | 1778 ++++++++++++++++++++ gitlab/src/index.ts | 5 + gitlab/src/review-strategy.ts | 335 ++++ gitlab/src/transport.ts | 139 ++ gitlab/src/version-commit-evolution.ts | 29 + gitlab/src/version-compare.ts | 1341 +++++++++++++++ gitlab/tsconfig.build.json | 13 + gitlab/vite.config.ts | 8 + package.json | 2 +- pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 1 + vite.config.ts | 7 + 16 files changed, 3903 insertions(+), 7 deletions(-) create mode 100644 gitlab/__tests__/history-transport.test.ts create mode 100644 gitlab/package.json create mode 100644 gitlab/src/crypto.ts create mode 100644 gitlab/src/history.ts create mode 100644 gitlab/src/index.ts create mode 100644 gitlab/src/review-strategy.ts create mode 100644 gitlab/src/transport.ts create mode 100644 gitlab/src/version-commit-evolution.ts create mode 100644 gitlab/src/version-compare.ts create mode 100644 gitlab/tsconfig.build.json create mode 100644 gitlab/vite.config.ts diff --git a/core/index.ts b/core/index.ts index 0a5d9053..e1f8bd16 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,4 +1,43 @@ export { defaultReviewPreferences } from './defaults.ts'; +export { + commitRevisionLabel, + diffComparison, + diffComparisonView, + diffRange, + evolutionUnitCommit, + evolutionUnitRebaseDrivers, + isReviewableUnit, + resolveReviewPlan, + reviewableUnits, + reviewCommitEvolution, + reviewVersionOption, + revisionRef, + versionOptionBaseCommitId, + versionOptionHeadCommitId, + versionOptionLabelText, + versionRevisionLabel, +} from './lib/review-history.ts'; + +export { + attributeRebaseDrivers, + createCommitPatchSignature, + matchVersionCommitStacks, + recommendVersionWalkthroughStructure, + scoreBaseCommitAsRebaseDriver, + toVersionCommitSummary, + versionCommitDiffConcurrency, + versionCommitEvolutionAlgorithmVersion, + versionCommitSignatureAlgorithmVersion, + versionCommitStackLimit, + type CommitPatchSignature, + type CommitStackEvolution, + type CommitStackEvolutionRange, + type DiffEndpointRef, + type VersionCommitEvolutionUnit, + type VersionCommitMatchKind, + type VersionCommitSummary, + type VersionRebaseDriverCommit, +} from './lib/commit-stack-evolution.ts'; export { parsePlanShareManifest, parsePlanShareUpload, @@ -9,9 +48,19 @@ export type { ChangedFile, CodiffFeatureFlags, CodiffPreferences, + DiffComparison, + DiffComparisonAnalysis, + DiffComparisonBaseMovement, + DiffComparisonBaseMovementCommit, + DiffComparisonCommentAssociation, + DiffComparisonSummary, + DiffComparisonView, + DiffRange, DiffSection, GitIdentity, NarrativeWalkthrough, + PullRequestAIReview, + PullRequestAIReviewDecision, PlanCommentThread, PullRequestCodeQualityFinding, PullRequestGeneralComment, @@ -23,13 +72,50 @@ export type { PullRequestReviewEvent, PullRequestReviewStatus, PullRequestReviewer, + ReviewCommitEvolution, + ReviewCommitListEntry, + ReviewCommitSummary, + ReviewEvolutionMarkerUnit, + ReviewEvolutionSummary, + ReviewEvolutionUnit, + ReviewPlan, ReviewPreferences, + ReviewRebaseDriverCommit, + ReviewStrategySummary, + ReviewStructureRecommendation, + ReviewUnit, + ReviewVersionOption, RepositoryState, ReviewSource, + RevisionLabel, + RevisionRef, SharePlanResult, ShareWalkthroughResult, SharedPlanSnapshot, SharedWalkthroughSnapshot, + WalkthroughCommentReference, + WalkthroughGenerationInput, WalkthroughShareManifestV1, WalkthroughHunk, } from './types.ts'; +export { + formatVersionElapsedDuration, + MergeRequestReviewApp, + ReadOnlyGeneralCommentCard, + ReviewSurface, + type MergeRequestCommitListEntry, + type MergeRequestReviewAppProps, + type MergeRequestReviewMode, + type MergeRequestReviewStrategySummary, + type MergeRequestVersionBaseMovement, + type MergeRequestVersionBaseMovementCommit, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCommitEvolutionUnit, + type MergeRequestVersionCommitSummary, + type MergeRequestVersionCompareCommentAssociation, + type MergeRequestVersionCompareSummary, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, + type MergeRequestVersionRebaseDriverCommit, + type MergeRequestWalkthroughStatus, +} from './SharedWalkthroughApp.tsx'; diff --git a/gitlab/__tests__/history-transport.test.ts b/gitlab/__tests__/history-transport.test.ts new file mode 100644 index 00000000..3fc167cb --- /dev/null +++ b/gitlab/__tests__/history-transport.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from 'vite-plus/test'; +import { + createFakeGitLabTransport, + fetchGitLabMergeRequestVersions, + projectCommitEvolution, + projectReviewPlan, + projectVersionCompare, + toGitLabDiffIdentity, +} from '../src/index.ts'; +import { matchVersionCommitStacks, createCommitPatchSignature } from '../src/version-commit-evolution.ts'; + +test('loads merge request versions through the injected transport', async () => { + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + response: [ + { + id: 2, + head_commit_sha: 'b'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-02T00:00:00.000Z', + }, + { + id: 1, + head_commit_sha: 'c'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + }, + ]); + + const versions = await fetchGitLabMergeRequestVersions({ + iid: 7, + projectPath: 'group/project', + transport, + }); + + expect(versions).toHaveLength(2); + expect(versions[0]?.id).toBe('2'); + expect(versions[0]?.label).toContain('v2'); + expect(toGitLabDiffIdentity(versions[0]!).headSha).toBe('b'.repeat(40)); + expect(transport.calls[0]?.path).toContain('/merge_requests/7/versions'); +}); + +test('projects algorithm evolution into Core review plans', async () => { + const from = { + baseSha: 'a'.repeat(40), + createdAt: '2026-01-01T00:00:00.000Z', + headSha: 'b'.repeat(40), + id: '1', + label: 'v1', + startSha: 'a'.repeat(40), + }; + const to = { + baseSha: 'a'.repeat(40), + createdAt: '2026-01-02T00:00:00.000Z', + headSha: 'c'.repeat(40), + id: '2', + label: 'v2', + startSha: 'a'.repeat(40), + }; + const oldCommit = { + authoredDate: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + message: 'feat: one\n', + parentIds: [from.baseSha], + sha: 'd'.repeat(40), + shortSha: 'ddddddd', + title: 'feat: one', + webUrl: 'https://example.test/d', + }; + const newCommit = { + ...oldCommit, + sha: 'e'.repeat(40), + shortSha: 'eeeeeee', + }; + const files = [ + { + fingerprint: 'f', + path: 'a.ts', + sections: [ + { + binary: false, + id: 'a.ts:commit:1', + kind: 'commit' as const, + patch: 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\n', + }, + ], + status: 'modified' as const, + }, + ]; + const oldSig = await createCommitPatchSignature(oldCommit, files); + const newSig = await createCommitPatchSignature(newCommit, files); + const evolution = await matchVersionCommitStacks({ + from, + newCommits: [newCommit], + oldCommits: [oldCommit], + signatures: new Map([ + [oldCommit.sha, oldSig], + [newCommit.sha, newSig], + ]), + to, + }); + const projected = projectCommitEvolution(evolution); + expect(projected.units.some((unit) => unit.kind === 'revised' || unit.kind === 'rewritten-same-patch' || unit.kind === 'retained')).toBe( + true, + ); + const plan = projectReviewPlan({ evolution, structure: 'auto' }); + expect(plan.structure === 'whole-diff' || plan.structure === 'units').toBe(true); +}); diff --git a/gitlab/package.json b/gitlab/package.json new file mode 100644 index 00000000..646baf3c --- /dev/null +++ b/gitlab/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-gitlab", + "version": "0.1.0", + "description": "Read-side GitLab review-history adapter for Codiff.", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "gitlab" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/gitlab/src/crypto.ts b/gitlab/src/crypto.ts new file mode 100644 index 00000000..c0e06c34 --- /dev/null +++ b/gitlab/src/crypto.ts @@ -0,0 +1,5 @@ +const bytesToHex = (buffer: ArrayBuffer) => + [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + +export const sha256 = async (value: string) => + bytesToHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); diff --git a/gitlab/src/history.ts b/gitlab/src/history.ts new file mode 100644 index 00000000..ce84c515 --- /dev/null +++ b/gitlab/src/history.ts @@ -0,0 +1,1778 @@ +/** + * Read-side GitLab review-history adapter over {@link GitLabTransport}. + */ +import type { + ChangedFile, + DiffComparisonAnalysis, + DiffComparisonView, + ReviewCommitEvolution, + ReviewCommitSummary, + ReviewEvolutionUnit, + ReviewPlan, + ReviewVersionOption, +} from '@nkzw/codiff-core/types'; +import { + commitRevisionLabel, + diffComparison, + diffComparisonView, + diffRange, + resolveReviewPlan, + reviewVersionOption, + revisionRef, + versionRevisionLabel, +} from '@nkzw/codiff-core'; +import type { GitLabTransport } from './transport.ts'; +import { + attributeRebaseDrivers, + createCommitPatchSignature, + matchVersionCommitStacks, + versionCommitDiffConcurrency, + versionCommitStackLimit, + type CommitPatchSignature, + type MergeRequestVersionCommitEvolution, + type VersionCommitEvolutionUnit, + type VersionRebaseDriverCommit, +} from './version-commit-evolution.ts'; +import { + computeVersionComparePreferringReplay, + type CommentAnchor, + type MergeRequestVersionCompare, + type MergeRequestVersionRef, + type VersionBaseMovement, + type VersionCompareEndpoint, + type VersionPatchFile, +} from './version-compare.ts'; + +export type { GitLabTransport } from './transport.ts'; +export type { + CommentAnchor, + MergeRequestVersionCompare, + MergeRequestVersionRef, + VersionCompareEndpoint, + VersionPatchFile, +} from './version-compare.ts'; +export type { + CommitPatchSignature, + MergeRequestVersionCommitEvolution, + VersionCommitEvolutionUnit, + VersionCommitMatchKind, + VersionCommitSummary, + VersionRebaseDriverCommit, +} from './version-commit-evolution.ts'; +export { + attributeRebaseDrivers, + createCommitPatchSignature, + matchVersionCommitStacks, + recommendVersionWalkthroughStructure, + toVersionCommitSummary, + versionCommitDiffConcurrency, + versionCommitEvolutionAlgorithmVersion, + versionCommitSignatureAlgorithmVersion, + versionCommitStackLimit, +} from './version-commit-evolution.ts'; +export { + applyUnifiedPatchBody, + computeApproximatePatchTextVersionCompare, + computeLineDiff, + computeRebaseReplayVersionCompare, + computeVersionComparePreferringReplay, + isMergeRequestVersionRef, + materializeRebaseReplayTrees, + versionCompareAlgorithmVersion, +} from './version-compare.ts'; +export { + classifyGitLabCommit, + classifyMergeRequestReviewStrategy, + orderCommitsTopologically, + overrideMergeRequestReviewStrategy, + reviewStructureFromStrategy, + versionCompareReviewStructureKey, + type ClassifiedCommit, + type ClassifiedCommitRole, + type GitLabMergeRequestCommitLike, + type MergeRequestReviewStrategy, +} from './review-strategy.ts'; +import { orderCommitsTopologically } from './review-strategy.ts'; + +const maxPages = 20; + +const gitLabOrigin = 'https://gitlab.cfdata.org'; + +export type GitLabDiffIdentity = { + baseSha: string; + headSha: string; + startSha: string; +}; + +export type GitLabMergeRequestCommit = { + authoredDate: string; + authorEmail: string; + authorName: string; + committedDate: string; + committerName: string; + message: string; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + title: string; + webUrl: string; +}; + +type JsonRecord = Record; +const isRecord = (value: unknown): value is JsonRecord => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const asRecord = (value: unknown): JsonRecord => (isRecord(value) ? value : {}); +const asArray = (value: unknown): Array => (Array.isArray(value) ? value : []); +const asString = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback); +const asNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; +const trimmedString = (value: unknown) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +export const validateProjectPath = (projectPath: string) => { + const normalized = projectPath.trim().replaceAll(/^\/+|\/+$/g, ''); + if ( + !normalized || + normalized.length > 500 || + !normalized.includes('/') || + normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error('Invalid GitLab project path.'); + } + return normalized; +}; + +const mergeRequestEndpoint = (projectPath: string, iid: number, suffix = '') => + `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/merge_requests/${iid}${suffix}`; + +const mergeRequestCommitsEndpoint = (projectPath: string, iid: number) => + mergeRequestEndpoint(projectPath, iid, '/commits'); + +const mergeRequestVersionsEndpoint = (projectPath: string, iid: number) => + mergeRequestEndpoint(projectPath, iid, '/versions'); + +const mergeRequestVersionEndpoint = (projectPath: string, iid: number, versionId: string) => + mergeRequestEndpoint(projectPath, iid, `/versions/${encodeURIComponent(versionId)}`); + +const repositoryCompareEndpoint = (projectPath: string, from: string, to: string) => { + const url = new URL( + `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/repository/compare`, + ); + url.searchParams.set('from', from); + url.searchParams.set('to', to); + url.searchParams.set('straight', 'true'); + return url.toString(); +}; + +const repositoryFileRawEndpoint = (projectPath: string, filePath: string, ref: string) => { + const url = new URL( + `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/repository/files/${encodeURIComponent(filePath)}/raw`, + ); + url.searchParams.set('ref', ref); + return url.toString(); +}; + +const repositoryCommitDiffEndpoint = (projectPath: string, sha: string) => + `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/repository/commits/${encodeURIComponent(sha)}/diff`; + +const repositoryCommitEndpoint = (projectPath: string, sha: string) => + `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/repository/commits/${encodeURIComponent(sha)}`; + + + + + + + +const createRequest = ( + pathOrUrl: string, + init: { method?: string; headers?: HeadersInit; body?: unknown } = {}, +) => { + // Accept absolute URLs from legacy helpers and reduce to path+query. + let path = pathOrUrl; + let query: Record | undefined; + if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { + const url = new URL(pathOrUrl); + path = url.pathname; + if (url.searchParams.toString()) { + query = Object.fromEntries(url.searchParams.entries()); + } + } else if (pathOrUrl.includes('?')) { + const url = new URL(pathOrUrl, 'https://gitlab.local'); + path = url.pathname; + query = Object.fromEntries(url.searchParams.entries()); + } + return { body: init.body, method: (init.method as any) ?? 'GET', path, query }; +}; + +const readJson = async ( + transport: GitLabTransport, + request: { path: string; query?: Record; method?: any; body?: unknown }, + _unavailableMessage: string, +) => transport.request({ + body: request.body, + method: request.method, + path: request.path, + query: request.query, +}); + +const readText = async ( + transport: GitLabTransport, + request: { path: string; query?: Record }, +) => { + if (!transport.requestText) { + throw new Error('GitLabTransport.requestText is required for raw blob reads.'); + } + return transport.requestText({ path: request.path, query: request.query }); +}; + +const readPages = async ( + transport: GitLabTransport, + url: string, +): Promise> => { + const first = createRequest(url); + if (transport.requestPages) { + return transport.requestPages({ path: first.path, query: first.query }); + } + const values: Array = []; + let page = 1; + while (page <= maxPages) { + const result = await transport.request({ + path: first.path, + query: { ...(first.query ?? {}), page, per_page: 100 }, + }); + const pageValues = asArray(result); + values.push(...pageValues); + if (pageValues.length < 100) break; + page += 1; + } + if (page > maxPages) { + throw new Error('GitLab merge request data exceeded the pagination limit.'); + } + return values; +}; + + + + + + + + + +const createPatch = (diff: JsonRecord) => { + const oldPath = asString(diff.old_path); + const newPath = asString(diff.new_path); + const body = asString(diff.diff); + const oldHeader = diff.new_file === true ? '/dev/null' : `a/${oldPath}`; + const newHeader = diff.deleted_file === true ? '/dev/null' : `b/${newPath}`; + return `diff --git a/${oldPath} b/${newPath}\n--- ${oldHeader}\n+++ ${newHeader}\n${body}${ + body.endsWith('\n') ? '' : '\n' + }`; +}; + +const normalizeDiff = ( + diffValue: unknown, + iid: number, + headSha: string, + index: number, + sectionKind: 'commit' | 'pull-request' = 'pull-request', + sectionScope: string = String(iid), +): ChangedFile | null => { + const diff = asRecord(diffValue); + const oldPath = asString(diff.old_path); + const newPath = asString(diff.new_path); + if (!oldPath || !newPath) { + return null; + } + const status = diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : diff.renamed_file + ? 'renamed' + : 'modified'; + const rawPatch = asString(diff.diff); + const unavailable = !rawPatch.trim(); + const sectionId = `${newPath}:${sectionKind}:${sectionScope}`; + return { + fingerprint: `${headSha}:${index}:${status}:${oldPath}:${newPath}:${rawPatch.length}`, + ...(oldPath !== newPath ? { oldPath } : {}), + path: newPath, + sections: [ + { + binary: false, + id: sectionId, + kind: sectionKind, + loadState: unavailable ? 'too-large' : 'ready', + patch: unavailable ? '' : createPatch(diff), + ...(unavailable + ? { + summary: { + canLoad: false, + reason: + diff.too_large === true + ? 'GitLab marked this diff as too large to display.' + : 'GitLab did not return a text patch for this file.', + }, + } + : {}), + }, + ], + status, + }; +}; + +const normalizeMergeRequestCommit = ( + value: unknown, + projectPath: string, +): GitLabMergeRequestCommit | null => { + const commit = asRecord(value); + const sha = asString(commit.id ?? commit.sha); + if (!sha) { + return null; + } + const title = asString(commit.title, asString(commit.message).split('\n')[0] || sha.slice(0, 8)); + const message = asString(commit.message, title); + const shortSha = asString(commit.short_id, sha.slice(0, 8)); + const parentIds = asArray(commit.parent_ids) + .map((parent) => asString(parent)) + .filter(Boolean); + const authorName = + trimmedString(commit.author_name) ?? trimmedString(asRecord(commit.author).name) ?? 'Unknown'; + const authorEmail = + trimmedString(commit.author_email) ?? trimmedString(asRecord(commit.author).email) ?? ''; + const authoredDate = + asString(commit.authored_date) || + asString(commit.created_at) || + asString(commit.committed_date) || + new Date(0).toISOString(); + const committerName = trimmedString(commit.committer_name) ?? authorName; + const committedDate = asString(commit.committed_date) || authoredDate; + return { + authoredDate, + authorEmail, + authorName, + committedDate, + committerName, + message, + parentIds, + sha, + shortSha, + title, + webUrl: + asString(commit.web_url) || + `${gitLabOrigin}/${projectPath}/-/commit/${encodeURIComponent(sha)}`, + }; +}; + +const normalizeVersionPatchFile = (value: unknown): VersionPatchFile | null => { + const diff = asRecord(value); + const oldPath = asString(diff.old_path); + const newPath = asString(diff.new_path); + if (!oldPath || !newPath) { + return null; + } + const status = diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : diff.renamed_file + ? 'renamed' + : 'modified'; + return { + newPath, + oldPath, + patchBody: asString(diff.diff), + status, + }; +}; + +const normalizeMergeRequestVersion = ( + value: unknown, + index: number, +): MergeRequestVersionRef | null => { + const version = asRecord(value); + const rawId = version.id; + const id = + (typeof rawId === 'string' && rawId) || + (typeof rawId === 'number' && Number.isFinite(rawId) ? String(rawId) : String(index + 1)); + const baseSha = asString(version.base_commit_sha); + const startSha = asString(version.start_commit_sha, baseSha); + const headSha = asString(version.head_commit_sha); + if (!baseSha || !headSha) { + return null; + } + const createdAt = asString(version.created_at, new Date(0).toISOString()); + const shortHead = headSha.slice(0, 7); + return { + baseSha, + createdAt, + headSha, + id, + label: `v${id} · ${shortHead}`, + startSha, + }; +}; + +// --- Commit / version / version-comparison GitLab surface (Fate root queries call these) --- +// Commits list → client commits mode. Commit diff → lazy onLoadCommitDiff. +// Versions + version comparison → version picker + version-comparison view (algorithm in version-compare.ts). +export const fetchGitLabMergeRequestCommits = async ({ + transport, + iid, + projectPath: rawProjectPath, +}: { + transport: GitLabTransport; + iid: number; + projectPath: string; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + if (!Number.isInteger(iid) || iid <= 0) { + throw new Error('Invalid GitLab merge request IID.'); + } + const values = await readPages( + transport, + mergeRequestCommitsEndpoint(projectPath, iid), + ); + return values + .map((value) => normalizeMergeRequestCommit(value, projectPath)) + .filter((commit): commit is GitLabMergeRequestCommit => commit != null); +}; + +export const fetchGitLabCommitDiff = async ({ + transport, + projectPath: rawProjectPath, + sha, +}: { + transport: GitLabTransport; + projectPath: string; + sha: string; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + const normalizedSha = sha.trim(); + if (!normalizedSha) { + throw new Error('A commit SHA is required.'); + } + const diffs = await readPages( + transport, + repositoryCommitDiffEndpoint(projectPath, normalizedSha), + ); + return diffs + .map((diff, index) => + normalizeDiff(diff, 0, normalizedSha, index, 'commit', normalizedSha.slice(0, 12)), + ) + .filter((file): file is ChangedFile => file != null) + .toSorted((first, second) => first.path.localeCompare(second.path)); +}; + +export type MergeRequestVersionDiffStat = { + additions: number; + deletions: number; + filesChanged: number; +}; + +/** + * MR-local version history. GitLab's `id` is deliberately retained only for + * server-side endpoint resolution; URLs use `number` (0 is the MR base). + */ +export type MergeRequestVersionHistoryEntry = { + createdAt: string | null; + diffStat: MergeRequestVersionDiffStat; + headSha: string; + id: string | null; + isHead: boolean; + number: number; + previousCreatedAt?: string; + previousNumber?: number; +}; + +type VersionStatCache = { + read?: (range: { + next: MergeRequestVersionRef; + previous: MergeRequestVersionRef; + }) => Promise | MergeRequestVersionDiffStat | null; + write?: ( + range: { next: MergeRequestVersionRef; previous: MergeRequestVersionRef }, + diffStat: MergeRequestVersionDiffStat, + ) => Promise | void; +}; + +const readGitLabMergeRequestVersions = async ({ + transport, + iid, + projectPath: rawProjectPath, +}: { + transport: GitLabTransport; + iid: number; + projectPath: string; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + if (!Number.isInteger(iid) || iid <= 0) { + throw new Error('Invalid GitLab merge request IID.'); + } + const values = await readPages( + transport, + mergeRequestVersionsEndpoint(projectPath, iid), + ); + return values + .map((value, index) => normalizeMergeRequestVersion(value, index)) + .filter((version): version is MergeRequestVersionRef => version != null) + .toSorted((first, second) => { + const timeDifference = Date.parse(first.createdAt) - Date.parse(second.createdAt); + return timeDifference || first.id.localeCompare(second.id); + }); +}; + +/** Versions ordered newest → oldest for existing versionCompare endpoint callers. */ +export const fetchGitLabMergeRequestVersions = async (args: { + transport: GitLabTransport; + iid: number; + projectPath: string; +}): Promise> => { + const versions = await readGitLabMergeRequestVersions(args); + return versions.toReversed().map((version, index) => ({ + ...version, + label: `v${versions.length - index} · ${version.headSha.slice(0, 7)}`, + })); +}; + +export const fetchGitLabMergeRequestVersionHistory = async ({ + cache, + transport, + iid, + projectPath, +}: { + cache?: VersionStatCache; + transport: GitLabTransport; + iid: number; + projectPath: string; +}): Promise> => { + const versions = await readGitLabMergeRequestVersions({ + transport, + iid, + projectPath, + }); + if (versions.length === 0) { + return []; + } + const stats = await Promise.all( + versions.map(async (version, index) => { + const previous = + versions[index - 1] ?? + ({ + ...version, + headSha: version.baseSha, + id: 'base', + label: 'MR base', + startSha: version.baseSha, + } satisfies MergeRequestVersionRef); + const cached = await cache?.read?.({ next: version, previous }); + if (cached) { + return cached; + } + // GitLab's version endpoint returns the whole MR patch for that version, + // not the delta from the preceding version. Compare the two endpoint + // SHAs so this is genuinely v(N-1) → vN (and base → v1). + const files = await readCompareFiles({ + from: previous.headSha, + transport, + projectPath, + to: version.headSha, + }); + const diffStat = { + additions: files.reduce( + (total, file) => + total + + file.patchBody + .split('\n') + .filter((line) => line.startsWith('+') && !line.startsWith('+++')).length, + 0, + ), + deletions: files.reduce( + (total, file) => + total + + file.patchBody + .split('\n') + .filter((line) => line.startsWith('-') && !line.startsWith('---')).length, + 0, + ), + filesChanged: files.length, + }; + await cache?.write?.({ next: version, previous }, diffStat); + return diffStat; + }), + ); + const first = versions[0]; + return [ + { + createdAt: null, + diffStat: stats[0]!, + headSha: first.baseSha, + id: null, + isHead: false, + number: 0, + }, + ...versions.map((version, index) => ({ + createdAt: version.createdAt, + diffStat: stats[index]!, + headSha: version.headSha, + id: version.id, + isHead: index === versions.length - 1, + number: index + 1, + ...(index > 0 + ? { previousCreatedAt: versions[index - 1]!.createdAt, previousNumber: index } + : {}), + })), + ]; +}; + +const readMergeRequestVersionFiles = async ({ + transport, + iid, + projectPath, + versionId, +}: { + transport: GitLabTransport; + iid: number; + projectPath: string; + versionId: string; +}): Promise> => { + const value = await readJson( + transport, + createRequest(mergeRequestVersionEndpoint(projectPath, iid, versionId)), + 'Unable to load the merge request version.', + ); + const version = asRecord(value); + return asArray(version.diffs ?? version.changes) + .map(normalizeVersionPatchFile) + .filter((file): file is VersionPatchFile => file != null); +}; +const readRepositoryCompare = async ({ + from, + transport, + projectPath, + to, +}: { + from: string; + transport: GitLabTransport; + projectPath: string; + to: string; +}): Promise => + asRecord( + await readJson( + transport, + createRequest(repositoryCompareEndpoint(projectPath, from, to)), + 'Unable to compare GitLab revisions.', + ), + ); + +const readCompareFiles = async (args: { + from: string; + transport: GitLabTransport; + projectPath: string; + to: string; +}): Promise> => { + const value = await readRepositoryCompare(args); + return asArray(value.diffs) + .map(normalizeVersionPatchFile) + .filter((file): file is VersionPatchFile => file != null); +}; + +const getPatchDiffStat = (files: ReadonlyArray) => ({ + additions: files.reduce( + (total, file) => + total + + file.patchBody.split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++')) + .length, + 0, + ), + deletions: files.reduce( + (total, file) => + total + + file.patchBody.split('\n').filter((line) => line.startsWith('-') && !line.startsWith('---')) + .length, + 0, + ), + filesChanged: files.length, +}); + +const commitGraphReaches = ( + targetSha: string, + ancestorSha: string, + candidates: ReadonlyArray, +) => { + const bySha = new Map(candidates.map((commit) => [commit.sha, commit])); + const visit = (sha: string, visited = new Set()): boolean => { + if (sha === ancestorSha) { + return true; + } + if (visited.has(sha)) { + return false; + } + visited.add(sha); + return (bySha.get(sha)?.parentIds ?? []).some((parent) => visit(parent, visited)); + }; + return visit(targetSha); +}; + +const toBaseMovementCommit = (commit: GitLabMergeRequestCommit) => ({ + authoredAt: commit.authoredDate, + authorName: commit.authorName, + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.title, + webUrl: commit.webUrl, +}); + +const readBaseMovement = async ({ + fromSha, + transport, + projectPath, + toSha, +}: { + fromSha: string; + transport: GitLabTransport; + projectPath: string; + toSha: string; +}): Promise => { + const baseRef = (sha: string, value?: unknown) => { + const commit = asRecord(value); + return { + committedAt: trimmedString(commit.committed_date) ?? null, + sha, + shortSha: trimmedString(commit.short_id) ?? sha.slice(0, 7), + webUrl: + trimmedString(commit.web_url) ?? + `${gitLabOrigin}/${projectPath}/-/commit/${encodeURIComponent(sha)}`, + }; + }; + if (fromSha === toSha) { + return { + changed: false, + commits: [], + commitsBetween: 0, + commitTimestampDeltaMs: null, + diffStat: { additions: 0, deletions: 0, filesChanged: 0 }, + from: baseRef(fromSha), + relationship: 'forward', + to: baseRef(toSha), + truncated: false, + }; + } + try { + const [fromValue, toValue, compare] = await Promise.all([ + readJson( + transport, + createRequest(repositoryCommitEndpoint(projectPath, fromSha)), + 'Unable to load the old base commit.', + ), + readJson( + transport, + createRequest(repositoryCommitEndpoint(projectPath, toSha)), + 'Unable to load the new base commit.', + ), + readRepositoryCompare({ from: fromSha, transport, projectPath, to: toSha }), + ]); + const from = baseRef(fromSha, fromValue); + const to = baseRef(toSha, toValue); + const commits = asArray(compare.commits) + .map((value) => normalizeMergeRequestCommit(value, projectPath)) + .filter((commit): commit is GitLabMergeRequestCommit => commit != null); + const forwardTruncated = compare.compare_timeout === true || compare.overflow === true; + let relationship: VersionBaseMovement['relationship'] = commitGraphReaches( + toSha, + fromSha, + commits, + ) + ? 'forward' + : 'unknown'; + // Prefer the new-base-facing commit list for UI expansion. For a pure + // backward base move, fall back to the reverse compare list. + let movementCommits = commits.map(toBaseMovementCommit); + let commitsBetween = relationship === 'forward' && !forwardTruncated ? commits.length : null; + let truncated = forwardTruncated; + if (relationship === 'unknown' && !forwardTruncated) { + try { + const reverse = await readRepositoryCompare({ + from: toSha, + transport, + projectPath, + to: fromSha, + }); + const reverseCommits = asArray(reverse.commits) + .map((value) => normalizeMergeRequestCommit(value, projectPath)) + .filter((commit): commit is GitLabMergeRequestCommit => commit != null); + const reverseTruncated = reverse.compare_timeout === true || reverse.overflow === true; + truncated = reverseTruncated; + if (commitGraphReaches(fromSha, toSha, reverseCommits)) { + relationship = 'backward'; + movementCommits = reverseCommits.map(toBaseMovementCommit); + commitsBetween = reverseTruncated ? null : reverseCommits.length; + } else if (!reverseTruncated) { + relationship = 'divergent'; + // From→to still describes the new base tip relative to the old one. + // Count those commits even when the histories diverged, so the UI can + // show base-diff stats and an expandable commit list. + commitsBetween = commits.length; + } + } catch { + relationship = 'unknown'; + } + } else if (relationship === 'unknown' && forwardTruncated && commits.length > 0) { + // GitLab may still return a partial commit list when compare overflows. + commitsBetween = commits.length; + } + const files = asArray(compare.diffs) + .map(normalizeVersionPatchFile) + .filter((file): file is VersionPatchFile => file != null); + const fromTimestamp = from.committedAt ? Date.parse(from.committedAt) : Number.NaN; + const toTimestamp = to.committedAt ? Date.parse(to.committedAt) : Number.NaN; + return { + changed: true, + commits: movementCommits, + commitsBetween, + commitTimestampDeltaMs: + Number.isFinite(fromTimestamp) && Number.isFinite(toTimestamp) + ? toTimestamp - fromTimestamp + : null, + diffStat: getPatchDiffStat(files), + from, + relationship, + to, + truncated, + }; + } catch (error) { + return { + changed: true, + commits: [], + commitsBetween: null, + commitTimestampDeltaMs: null, + diffStat: null, + from: baseRef(fromSha), + relationship: 'unknown', + to: baseRef(toSha), + truncated: false, + warning: error instanceof Error ? error.message : 'Base movement details are unavailable.', + }; + } +}; + +const resolveVersionCompareEndpoint = ({ + comments, + endpoint, + lastReviewed, + versions, +}: { + comments?: ReadonlyArray; + endpoint: VersionCompareEndpoint; + lastReviewed?: MergeRequestVersionRef | null; + versions: ReadonlyArray; +}): MergeRequestVersionRef => { + if (endpoint.kind === 'mr-base') { + const oldest = versions.at(-1); + if (!oldest) { + throw new Error('No merge request base is available.'); + } + return { + ...oldest, + headSha: oldest.baseSha, + id: 'base', + label: 'MR base', + startSha: oldest.baseSha, + }; + } + if (endpoint.kind === 'mr-version') { + const match = versions.find((version) => version.id === endpoint.versionId); + if (!match) { + throw new Error(`Unknown merge request version: ${endpoint.versionId}`); + } + return match; + } + if (endpoint.kind === 'diff-identity') { + return { + baseSha: endpoint.baseSha, + createdAt: new Date(0).toISOString(), + headSha: endpoint.headSha, + id: `identity:${endpoint.headSha}`, + label: endpoint.headSha.slice(0, 7), + startSha: endpoint.startSha, + }; + } + if (endpoint.kind === 'head-sha') { + const match = versions.find((version) => version.headSha === endpoint.headSha); + if (match) { + return match; + } + return { + baseSha: versions[0]?.baseSha ?? endpoint.headSha, + createdAt: new Date(0).toISOString(), + headSha: endpoint.headSha, + id: `head:${endpoint.headSha}`, + label: endpoint.headSha.slice(0, 7), + startSha: versions[0]?.startSha ?? versions[0]?.baseSha ?? endpoint.headSha, + }; + } + if (endpoint.kind === 'last-reviewed') { + if (!lastReviewed) { + throw new Error('No last-reviewed identity is available for this merge request.'); + } + return lastReviewed; + } + if (endpoint.kind === 'comment-position') { + const match = (comments ?? []).find((comment) => comment.commentId === endpoint.commentId); + if (!match) { + throw new Error(`Unknown comment position for version comparison: ${endpoint.commentId}`); + } + const byHead = versions.find((version) => version.headSha === match.position.headSha); + if (byHead) { + return byHead; + } + return { + baseSha: match.position.baseSha, + createdAt: new Date(0).toISOString(), + headSha: match.position.headSha, + id: `comment:${endpoint.commentId}`, + label: `comment ${endpoint.commentId} · ${match.position.headSha.slice(0, 7)}`, + startSha: match.position.startSha, + }; + } + throw new Error('Unsupported version-comparison endpoint.'); +}; + +const collectCommentAnchorsFromDiscussions = ( + discussions: ReadonlyArray, +): Array => + discussions.flatMap((discussionValue) => { + const discussion = asRecord(discussionValue); + return asArray(discussion.notes).flatMap((noteValue) => { + const note = asRecord(noteValue); + if (note.system === true) { + return []; + } + const position = asRecord(note.position ?? note.original_position); + const filePath = asString(position.new_path ?? position.old_path); + const baseSha = asString(position.base_sha); + const startSha = asString(position.start_sha); + const headSha = asString(position.head_sha); + const id = asNumber(note.id); + if (!filePath || !baseSha || !startSha || !headSha || id == null || !Number.isInteger(id)) { + return []; + } + const rawLine = asNumber(position.new_line ?? position.old_line); + const anchor: CommentAnchor = { + commentId: `gitlab:${id}`, + filePath, + position: { baseSha, headSha, startSha }, + }; + if (rawLine != null && Number.isInteger(rawLine) && rawLine > 0) { + anchor.lineNumber = rawLine; + } + return [anchor]; + }); + }); + +export const fetchGitLabMergeRequestVersionCompare = async ({ + comments = [], + from: fromEndpoint, + transport, + iid, + lastReviewed = null, + paths, + projectPath: rawProjectPath, + readCached, + to: toEndpoint, + writeCached, +}: { + comments?: ReadonlyArray; + from: VersionCompareEndpoint; + transport: GitLabTransport; + iid: number; + lastReviewed?: MergeRequestVersionRef | null; + paths?: ReadonlyArray; + projectPath: string; + readCached?: (range: { + from: MergeRequestVersionRef; + to: MergeRequestVersionRef; + }) => Promise | MergeRequestVersionCompare | null; + to: VersionCompareEndpoint; + writeCached?: (versionCompare: MergeRequestVersionCompare) => Promise | void; +}): Promise => { + const projectPath = validateProjectPath(rawProjectPath); + if (!Number.isInteger(iid) || iid <= 0) { + throw new Error('Invalid GitLab merge request IID.'); + } + const needsCommentAnchors = + comments.length === 0 || + fromEndpoint.kind === 'comment-position' || + toEndpoint.kind === 'comment-position'; + const [versions, discussionAnchors] = await Promise.all([ + fetchGitLabMergeRequestVersions({ + transport, + iid, + projectPath, + }), + needsCommentAnchors + ? readPages( + transport, + mergeRequestEndpoint(projectPath, iid, '/discussions'), + ).then(collectCommentAnchorsFromDiscussions) + : Promise.resolve([] as Array), + ]); + if (versions.length === 0) { + throw new Error('GitLab did not return merge request versions for version comparison.'); + } + const resolvedComments = comments.length > 0 ? comments : discussionAnchors; + const from = resolveVersionCompareEndpoint({ + comments: resolvedComments, + endpoint: fromEndpoint, + lastReviewed, + versions, + }); + const to = resolveVersionCompareEndpoint({ + comments: resolvedComments, + endpoint: toEndpoint, + lastReviewed, + versions, + }); + if (from.headSha === to.headSha && from.baseSha === to.baseSha) { + throw new Error('Version comparison requires distinct from and to endpoints.'); + } + if (readCached) { + const cached = await readCached({ from, to }); + if (cached) { + return cached; + } + } + + const baseMovementPromise = readBaseMovement({ + fromSha: from.baseSha, + transport, + projectPath, + toSha: to.baseSha, + }); + + const loadVersionFiles = async (version: MergeRequestVersionRef) => { + const listed = versions.find((candidate) => candidate.id === version.id); + if (listed) { + try { + return await readMergeRequestVersionFiles({ + transport, + iid, + projectPath, + versionId: version.id, + }); + } catch { + // Fall through to repository compare. + } + } + return readCompareFiles({ + from: version.baseSha, + transport, + projectPath, + to: version.headSha, + }); + }; + + const [fromFiles, toFiles] = await Promise.all([loadVersionFiles(from), loadVersionFiles(to)]); + const blobCache = new Map(); + const readBlob = async (filePath: string, ref: string) => { + const key = `${ref}:${filePath}`; + if (blobCache.has(key)) { + return blobCache.get(key) ?? null; + } + try { + const content = await readText( + transport, + createRequest(repositoryFileRawEndpoint(projectPath, filePath, ref), { + headers: { accept: 'text/plain' }, + }), + ); + blobCache.set(key, content); + return content; + } catch { + blobCache.set(key, null); + return null; + } + }; + const computedVersionCompare = await computeVersionComparePreferringReplay({ + comments: resolvedComments, + from, + fromFiles, + paths, + readBlob, + to, + toFiles, + }); + const versionCompare: MergeRequestVersionCompare = { + ...computedVersionCompare, + baseMovement: await baseMovementPromise, + }; + if (writeCached) { + try { + await writeCached(versionCompare); + } catch { + // Cache writes are best-effort. + } + } + return versionCompare; +}; + +const resolveVersionCommitRange = async ({ + from, + transport, + iid, + projectPath, + to, +}: { + from: VersionCompareEndpoint; + transport: GitLabTransport; + iid: number; + projectPath: string; + to: VersionCompareEndpoint; +}) => { + const versions = + from.kind === 'diff-identity' && to.kind === 'diff-identity' + ? [] + : await fetchGitLabMergeRequestVersions({ + transport, + iid, + projectPath, + }); + if (versions.length === 0 && (from.kind !== 'diff-identity' || to.kind !== 'diff-identity')) { + throw new Error('GitLab did not return merge request versions.'); + } + return { + from: resolveVersionCompareEndpoint({ endpoint: from, versions }), + to: resolveVersionCompareEndpoint({ endpoint: to, versions }), + }; +}; + +export const fetchGitLabHistoricalCommitStack = async ({ + baseSha, + transport, + headSha, + projectPath: rawProjectPath, +}: { + baseSha: string; + transport: GitLabTransport; + headSha: string; + projectPath: string; +}): Promise> => { + if (baseSha === headSha) { + return []; + } + const projectPath = validateProjectPath(rawProjectPath); + const compare = await readRepositoryCompare({ + from: baseSha, + transport, + projectPath, + to: headSha, + }); + if (compare.compare_timeout === true || compare.overflow === true) { + throw new Error('GitLab truncated the historical commit stack.'); + } + const commits = asArray(compare.commits) + .map((value) => normalizeMergeRequestCommit(value, projectPath)) + .filter( + (commit): commit is GitLabMergeRequestCommit => commit != null && commit.sha !== baseSha, + ); + return [...orderCommitsTopologically(commits)]; +}; + +const readCommitPatchFiles = async ({ + transport, + projectPath, + sha, +}: { + transport: GitLabTransport; + projectPath: string; + sha: string; +}) => { + const values = await readPages( + transport, + repositoryCommitDiffEndpoint(projectPath, sha), + ); + return values + .map(normalizeVersionPatchFile) + .filter((file): file is VersionPatchFile => file != null); +}; + +const scopeVersionCommitFiles = ( + files: ReadonlyArray, + unitId: string, +): Array => + files.map((file, fileIndex) => ({ + ...file, + fingerprint: `${unitId}:${fileIndex}:${file.fingerprint}`, + sections: file.sections.map((section, sectionIndex) => ({ + ...section, + id: `${file.path}:version-commit:${unitId}:${sectionIndex}`, + })), + })); + +type VersionCommitSignatureCache = { + read?: (sha: string) => Promise | CommitPatchSignature | null; + write?: (signature: CommitPatchSignature) => Promise | void; +}; + +export const fetchGitLabMergeRequestVersionCommitEvolution = async ({ + cache, + from: fromEndpoint, + transport, + iid, + projectPath: rawProjectPath, + to: toEndpoint, +}: { + cache?: VersionCommitSignatureCache; + from: VersionCompareEndpoint; + transport: GitLabTransport; + iid: number; + projectPath: string; + to: VersionCompareEndpoint; +}): Promise => { + const projectPath = validateProjectPath(rawProjectPath); + const range = await resolveVersionCommitRange({ + from: fromEndpoint, + transport, + iid, + projectPath, + to: toEndpoint, + }); + const warnings: Array = []; + const [oldStackResult, newStackResult, baseStackResult] = await Promise.allSettled([ + fetchGitLabHistoricalCommitStack({ + baseSha: range.from.baseSha, + transport, + headSha: range.from.headSha, + projectPath, + }), + fetchGitLabHistoricalCommitStack({ + baseSha: range.to.baseSha, + transport, + headSha: range.to.headSha, + projectPath, + }), + range.from.baseSha === range.to.baseSha + ? Promise.resolve([]) + : fetchGitLabHistoricalCommitStack({ + baseSha: range.from.baseSha, + transport, + headSha: range.to.baseSha, + projectPath, + }), + ]); + const stackCompleteness = { + new: newStackResult.status === 'fulfilled', + old: oldStackResult.status === 'fulfilled', + }; + let baseStackComplete = baseStackResult.status === 'fulfilled'; + let oldCommits = oldStackResult.status === 'fulfilled' ? oldStackResult.value : []; + let newCommits = newStackResult.status === 'fulfilled' ? newStackResult.value : []; + let baseCommits = baseStackResult.status === 'fulfilled' ? baseStackResult.value : []; + if (!stackCompleteness.old) { + warnings.push( + 'The earlier commit stack is unavailable. Visible commits remain unclassified rather than being called new.', + ); + } + if (!stackCompleteness.new) { + warnings.push( + 'The later commit stack is unavailable. Visible commits remain unclassified rather than being called removed.', + ); + } + if (baseStackResult.status !== 'fulfilled') { + warnings.push( + 'Target-base movement could not be analyzed. Earlier commits that moved into the base remain unclassified.', + ); + } + if (oldCommits.length > versionCommitStackLimit) { + oldCommits = oldCommits.slice(-versionCommitStackLimit); + stackCompleteness.old = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the earlier version were analyzed; unmatched commits remain unclassified.`, + ); + } + if (newCommits.length > versionCommitStackLimit) { + newCommits = newCommits.slice(-versionCommitStackLimit); + stackCompleteness.new = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the later version were analyzed; unmatched commits remain unclassified.`, + ); + } + if (baseCommits.length > versionCommitStackLimit) { + baseCommits = baseCommits.slice(-versionCommitStackLimit); + baseStackComplete = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} target-base commits were analyzed; earlier commits that moved into the base may remain unclassified.`, + ); + } + const sameShas = new Set( + oldCommits + .map((commit) => commit.sha) + .filter( + (sha) => + newCommits.some((commit) => commit.sha === sha) || + baseCommits.some((commit) => commit.sha === sha), + ), + ); + const commitsNeedingSignatures = [...oldCommits, ...newCommits, ...baseCommits].filter( + (commit, index, commits) => + !sameShas.has(commit.sha) && + commits.findIndex((candidate) => candidate.sha === commit.sha) === index, + ); + const signatures = new Map(); + const baseCommitShas = new Set(baseCommits.map((commit) => commit.sha)); + let failedBaseSignatureCount = 0; + let failedSignatureCount = 0; + for ( + let index = 0; + index < commitsNeedingSignatures.length; + index += versionCommitDiffConcurrency + ) { + const batch = commitsNeedingSignatures.slice(index, index + versionCommitDiffConcurrency); + await Promise.all( + batch.map(async (commit) => { + try { + const cached = await cache?.read?.(commit.sha); + if (cached) { + signatures.set(commit.sha, cached); + return; + } + } catch { + // A missing/stale cache must not prevent direct analysis. + } + try { + const files = await fetchGitLabCommitDiff({ + transport, + projectPath, + sha: commit.sha, + }); + const signature = await createCommitPatchSignature(commit, files); + signatures.set(commit.sha, signature); + try { + await cache?.write?.(signature); + } catch { + // Immutable signature cache writes are best-effort. + } + } catch { + if (baseCommitShas.has(commit.sha)) { + failedBaseSignatureCount += 1; + } else { + failedSignatureCount += 1; + } + } + }), + ); + } + if (failedSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedSignatureCount} ${failedSignatureCount === 1 ? 'commit' : 'commits'}; they remain unclassified rather than being called new or removed.`, + ); + stackCompleteness.old = false; + stackCompleteness.new = false; + } + if (failedBaseSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedBaseSignatureCount} target-base ${failedBaseSignatureCount === 1 ? 'commit' : 'commits'}; earlier MR commits are only marked as removed when base evidence is complete.`, + ); + baseStackComplete = false; + } + return matchVersionCommitStacks({ + baseCommits, + baseStackComplete, + from: range.from, + newCommits, + oldCommits, + signatures, + stackCompleteness, + to: range.to, + warnings, + }); +}; + +export const attributeVersionCommitRebaseDrivers = async ({ + baseCommits, + transport, + projectPath, + unit, + unitFiles, +}: { + baseCommits: ReadonlyArray<{ + authoredAt: string; + authorName: string; + sha: string; + shortSha: string; + subject: string; + webUrl: string; + }>; + transport: GitLabTransport; + projectPath: string; + unit: VersionCommitEvolutionUnit; + unitFiles: ReadonlyArray; +}): Promise> => { + if (unit.kind !== 'likely-revised' || baseCommits.length === 0 || unitFiles.length === 0) { + return unit.rebaseDrivers ?? []; + } + const unitSignature = await createCommitPatchSignature( + { + sha: unit.after?.sha ?? unit.before?.sha ?? unit.id, + title: unit.after?.subject ?? unit.before?.subject ?? unit.id, + }, + unitFiles, + ); + const signatures = new Map(); + // Cap base-commit signature work; large base moves are common on revived MRs. + const candidates = baseCommits.slice(0, 40); + for (let index = 0; index < candidates.length; index += versionCommitDiffConcurrency) { + const batch = candidates.slice(index, index + versionCommitDiffConcurrency); + await Promise.all( + batch.map(async (commit) => { + try { + const files = await fetchGitLabCommitDiff({ + transport, + projectPath, + sha: commit.sha, + }); + signatures.set( + commit.sha, + await createCommitPatchSignature({ sha: commit.sha, title: commit.subject }, files), + ); + } catch { + // Skip unreadable base commits rather than failing the unit walkthrough. + } + }), + ); + } + return attributeRebaseDrivers({ + baseCommits: candidates, + baseSignatures: signatures, + unitSignature, + }); +}; + +export const fetchGitLabVersionCommitUnitDiff = async ({ + transport, + projectPath: rawProjectPath, + unit, +}: { + transport: GitLabTransport; + projectPath: string; + unit: VersionCommitEvolutionUnit; +}): Promise> => { + const projectPath = validateProjectPath(rawProjectPath); + if (!unit.reviewable) { + throw new Error('This commit evolution unit is not reviewable.'); + } + if (unit.kind === 'added' && unit.after) { + return scopeVersionCommitFiles( + await fetchGitLabCommitDiff({ transport, projectPath, sha: unit.after.sha }), + unit.id, + ); + } + if (unit.kind === 'removed' && unit.before) { + const parent = unit.before.parentIds[0]; + if (!parent) { + throw new Error('The removed commit parent is unavailable.'); + } + const compare = await readRepositoryCompare({ + from: unit.before.sha, + transport, + projectPath, + to: parent, + }); + return scopeVersionCommitFiles( + asArray(compare.diffs) + .map((diff, index) => normalizeDiff(diff, 0, parent, index, 'commit', unit.id)) + .filter((file): file is ChangedFile => file != null), + unit.id, + ); + } + if (unit.kind === 'likely-revised' && unit.before && unit.after) { + const oldParent = unit.before.parentIds[0]; + const newParent = unit.after.parentIds[0]; + if (!oldParent || !newParent) { + throw new Error('A revised commit parent is unavailable.'); + } + const [fromFiles, toFiles] = await Promise.all([ + readCommitPatchFiles({ transport, projectPath, sha: unit.before.sha }), + readCommitPatchFiles({ transport, projectPath, sha: unit.after.sha }), + ]); + const blobs = new Map(); + const readBlob = async (filePath: string, ref: string) => { + const key = `${ref}:${filePath}`; + if (blobs.has(key)) { + return blobs.get(key) ?? null; + } + try { + const content = await readText( + transport, + createRequest(repositoryFileRawEndpoint(projectPath, filePath, ref), { + headers: { accept: 'text/plain' }, + }), + ); + blobs.set(key, content); + return content; + } catch { + blobs.set(key, null); + return null; + } + }; + const comparison = await computeVersionComparePreferringReplay({ + from: { + baseSha: oldParent, + createdAt: unit.before.authoredAt, + headSha: unit.before.sha, + id: unit.before.sha, + label: unit.before.shortSha, + startSha: oldParent, + }, + fromFiles, + readBlob, + to: { + baseSha: newParent, + createdAt: unit.after.authoredAt, + headSha: unit.after.sha, + id: unit.after.sha, + label: unit.after.shortSha, + startSha: newParent, + }, + toFiles, + }); + return scopeVersionCommitFiles( + comparison.files.map((file) => file.file), + unit.id, + ); + } + throw new Error('Unsupported commit evolution unit.'); +}; + +export const projectMergeRequestVersionRef = ( + version: MergeRequestVersionRef & { number?: number; createdAt?: string; diffStat?: ReviewVersionOption['diffStat']; isHead?: boolean; previousCreatedAt?: string; previousNumber?: number }, +): ReviewVersionOption => + reviewVersionOption({ + createdAt: version.createdAt, + id: version.id, + range: diffRange( + revisionRef(version.baseSha, commitRevisionLabel(version.baseSha.slice(0, 7))), + revisionRef( + version.headSha, + versionRevisionLabel(version.label, undefined), + ), + ), + ...(version.diffStat ? { diffStat: version.diffStat } : {}), + ...(version.isHead != null ? { isHead: version.isHead } : {}), + ...(version.number != null ? { number: version.number } : {}), + ...(version.previousCreatedAt ? { previousCreatedAt: version.previousCreatedAt } : {}), + ...(version.previousNumber != null ? { previousNumber: version.previousNumber } : {}), + }); + +const projectCommitSummary = (commit: Algorithmish | undefined): ReviewCommitSummary | undefined => { + if (!commit) return undefined; + return { + authorName: commit.authorName, + authoredAt: commit.authoredAt, + parentIds: commit.parentIds ?? [], + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + webUrl: commit.webUrl, + ...(commit.diffStat ? { diffStat: commit.diffStat } : {}), + }; +}; + +type Algorithmish = { + authorName: string; + authoredAt: string; + diffStat?: { additions: number; deletions: number; filesChanged: number }; + parentIds?: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export const projectEvolutionUnit = (unit: { + after?: Algorithmish; + baseCommit?: Algorithmish; + before?: Algorithmish; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + kind: string; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + rebaseDrivers?: ReadonlyArray<{ + authorName: string; + authoredAt: string; + overlappingPaths: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }>; + reviewable: boolean; +}): ReviewEvolutionUnit => { + const common = { + confidence: unit.confidence, + id: unit.id, + order: unit.order, + ...(unit.matchReasons ? { matchReasons: unit.matchReasons } : {}), + ...(unit.matchScore != null ? { matchScore: unit.matchScore } : {}), + } as const; + if (unit.kind === 'added' || unit.kind === 'introduced') { + return { + ...common, + after: projectCommitSummary(unit.after)!, + kind: 'introduced', + reviewable: true, + }; + } + if (unit.kind === 'removed') { + return { + ...common, + before: projectCommitSummary(unit.before)!, + kind: 'removed', + reviewable: true, + }; + } + if (unit.kind === 'likely-revised' || unit.kind === 'revised') { + return { + ...common, + after: projectCommitSummary(unit.after)!, + before: projectCommitSummary(unit.before)!, + kind: 'revised', + reviewable: true, + ...(unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + authorName: driver.authorName, + authoredAt: driver.authoredAt, + overlappingPaths: driver.overlappingPaths, + sha: driver.sha, + shortSha: driver.shortSha, + subject: driver.subject, + webUrl: driver.webUrl, + })), + } + : {}), + }; + } + if (unit.kind === 'ambiguous') { + return { + ...common, + kind: 'ambiguous', + reviewable: true, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; + } + if (unit.kind === 'absorbed-into-base') { + return { + ...common, + kind: 'absorbed-into-base', + reviewable: false, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.baseCommit) ? { baseCommit: projectCommitSummary(unit.baseCommit) } : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; + } + // retained / rewritten-same-patch + return { + ...common, + kind: unit.kind === 'rewritten-same-patch' ? 'rewritten-same-patch' : 'retained', + reviewable: false, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; +}; + +export const projectCommitEvolution = ( + evolution: MergeRequestVersionCommitEvolution, +): ReviewCommitEvolution => ({ + recommendation: { + rationale: evolution.recommendation.reason, + suggestedStructure: evolution.recommendation.structure, + }, + summary: evolution.summary, + units: evolution.units.map(projectEvolutionUnit), + ...(evolution.warnings ? { warnings: evolution.warnings } : {}), +}); + +export const projectVersionCompare = ( + compare: MergeRequestVersionCompare, + files: ReadonlyArray = compare.files.map((file) => file.file), +): DiffComparisonView => { + const from = projectMergeRequestVersionRef(compare.range.from); + const to = projectMergeRequestVersionRef(compare.range.to); + const analysis: DiffComparisonAnalysis = { + summary: compare.summary, + ...(compare.baseMovement ? { baseMovement: compare.baseMovement } : {}), + ...(compare.commentAssociations + ? { commentAssociations: compare.commentAssociations } + : {}), + ...(compare.warnings ? { warnings: compare.warnings } : {}), + }; + return diffComparisonView({ + analysis, + comparison: diffComparison(from.range, to.range), + files, + from, + to, + }); +}; + +export const projectReviewPlan = ({ + evolution, + structure, + versionCompare, +}: { + evolution?: MergeRequestVersionCommitEvolution | ReviewCommitEvolution | null; + structure?: 'auto' | 'commit-by-commit' | 'whole-diff' | 'units'; + versionCompare?: MergeRequestVersionCompare | DiffComparisonView | null; +}): ReviewPlan => { + const projectedEvolution = + evolution && 'recommendation' in evolution && 'reason' in (evolution as MergeRequestVersionCommitEvolution).recommendation + ? projectCommitEvolution(evolution as MergeRequestVersionCommitEvolution) + : (evolution as ReviewCommitEvolution | undefined); + const view = + versionCompare && 'range' in versionCompare + ? projectVersionCompare(versionCompare) + : (versionCompare as DiffComparisonView | undefined); + const analysis = view + ? { + ...view.analysis, + ...(projectedEvolution ? { commitEvolution: projectedEvolution } : {}), + } + : projectedEvolution + ? { + commitEvolution: projectedEvolution, + summary: { + addedLines: 0, + baseMoved: false, + commentsAffected: 0, + conflictFiles: 0, + deletedLines: 0, + empty: false, + filesChanged: 0, + intentionalFiles: 0, + noiseFiles: 0, + }, + } + : undefined; + return resolveReviewPlan({ + analysis, + comparison: view?.comparison, + recommendation: projectedEvolution?.recommendation, + structure, + units: projectedEvolution?.units, + }); +}; + +export const toGitLabDiffIdentity = (version: MergeRequestVersionRef): GitLabDiffIdentity => ({ + baseSha: version.baseSha, + headSha: version.headSha, + startSha: version.startSha, +}); diff --git a/gitlab/src/index.ts b/gitlab/src/index.ts new file mode 100644 index 00000000..1632c45a --- /dev/null +++ b/gitlab/src/index.ts @@ -0,0 +1,5 @@ +export * from './history.ts'; +export * from './review-strategy.ts'; +export * from './transport.ts'; +export * from './version-commit-evolution.ts'; +export * from './version-compare.ts'; diff --git a/gitlab/src/review-strategy.ts b/gitlab/src/review-strategy.ts new file mode 100644 index 00000000..5818aa1c --- /dev/null +++ b/gitlab/src/review-strategy.ts @@ -0,0 +1,335 @@ +// Review-structure classifier for GitLab MRs. +// Entry: classifyMergeRequestReviewStrategy() — used by merge-request snapshot load. +// Consumers: walkthrough prompt guidance, walkthrough cache identity (reviewStructure), +// and the client commits/strategy summary. See PLAN.md §G1. + +export type ClassifiedCommitRole = + | 'chore' + | 'docs' + | 'feature' + | 'fixup' + | 'merge' + | 'refactor' + | 'revert' + | 'review-response' + | 'test' + | 'unknown'; + +export type ClassifiedCommit = { + authoredAt: string; + authorName: string; + body: string; + isMerge: boolean; + parents: ReadonlyArray; + role: ClassifiedCommitRole; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +export type MergeRequestReviewStrategy = + | { + commits: ReadonlyArray; + confidence: number; + mode: 'commit-by-commit'; + reason: 'chapter-shaped' | 'explicit-description' | 'stacked-subjects' | 'user-override'; + } + | { + confidence: number; + mode: 'whole-mr'; + reason: + | 'default' + | 'explicit-whole' + | 'fixup-style' + | 'review-response-style' + | 'single-commit' + | 'too-many-commits' + | 'user-override'; + }; + +export type GitLabMergeRequestCommitLike = { + authoredDate: string; + authorName: string; + message: string; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + title: string; + webUrl?: string; +}; + +const explicitCommitByCommitPattern = + /\b(?:review\s+commit(?:s)?(?:\s+by\s+commit|[- ]by[- ]commit)|please\s+review\s+each\s+commit|commit[- ]wise\s+review|stacked\s+diff|stacked\s+commits)\b/i; + +const explicitWholePattern = + /\b(?:review\s+as\s+a\s+whole|ignore\s+commits|squash\s+on\s+merge)\b/i; + +const fixupSubjectPattern = + /^(?:fixup!|squash!|amend!|wip\b|tmp\b|try\b|rework\b|review\s+feedback|pr\s+feedback|mr\s+feedback|nits?\b|typo\b|oops\b|cleanup\s+after|follow[- ]?up\b)/i; + +const reviewResponseSubjectPattern = + /^(?:address(?:ing)?\s+(?:review|comments?|feedback)|respond(?:ing)?\s+to\s+(?:review|comments?|feedback)|review\s+comments?)\b/i; + +const conventionalCommitPattern = + /^(?:feat|fix|refactor|test|docs|chore|perf|build|ci|style|revert)(?:\(.+\))?!?:/i; + +const numberedStepPattern = /^(?:step\s+)?\d+[.):\-\s]/i; + +const shortShaPattern = /\b[0-9a-f]{7,40}\b/gi; + +const classifyCommitRole = ( + subject: string, + parentIds: ReadonlyArray, +): ClassifiedCommitRole => { + if (parentIds.length > 1 || /^merge\b/i.test(subject)) { + return 'merge'; + } + if (fixupSubjectPattern.test(subject)) { + return 'fixup'; + } + if (reviewResponseSubjectPattern.test(subject)) { + return 'review-response'; + } + if (/^revert\b/i.test(subject) || subject.startsWith('Revert "')) { + return 'revert'; + } + if (/^(?:test|tests?)(?:\b|[:(\s])/i.test(subject) || /^test(?:\(.+\))?!?:/i.test(subject)) { + return 'test'; + } + if (/^(?:docs?)(?:\b|[:(\s])/i.test(subject) || /^docs?(?:\(.+\))?!?:/i.test(subject)) { + return 'docs'; + } + if (/^refactor(?:\b|[:(\s])/i.test(subject) || /^refactor(?:\(.+\))?!?:/i.test(subject)) { + return 'refactor'; + } + if (/^(?:chore|build|ci)(?:\b|[:(\s])/i.test(subject)) { + return 'chore'; + } + if ( + /^(?:feat|feature|add|implement)(?:\b|[:(\s])/i.test(subject) || + /^feat(?:\(.+\))?!?:/i.test(subject) + ) { + return 'feature'; + } + return 'unknown'; +}; + +const splitMessage = (message: string, title: string) => { + const normalized = message.trim() || title.trim(); + const [subjectLine = title.trim() || 'Commit', ...rest] = normalized.split('\n'); + return { + body: rest.join('\n').trim(), + subject: subjectLine.trim() || title.trim() || 'Commit', + }; +}; + +export const classifyGitLabCommit = (commit: GitLabMergeRequestCommitLike): ClassifiedCommit => { + const { body, subject } = splitMessage(commit.message, commit.title); + const role = classifyCommitRole(subject, commit.parentIds); + return { + authoredAt: commit.authoredDate, + authorName: commit.authorName, + body, + isMerge: role === 'merge' || commit.parentIds.length > 1, + parents: commit.parentIds, + role, + sha: commit.sha, + shortSha: commit.shortSha || commit.sha.slice(0, 8), + subject, + ...(commit.webUrl ? { webUrl: commit.webUrl } : {}), + }; +}; + +/** + * Orders the commits in an MR from its base toward its head. GitLab's commits + * endpoint does not promise the direction we need for a reviewer walkthrough, + * so never use its response order as chronology. + */ +export const orderCommitsTopologically = < + Commit extends { + parentIds?: ReadonlyArray; + parents?: ReadonlyArray; + sha: string; + }, +>( + commits: ReadonlyArray, +): ReadonlyArray => { + const bySha = new Map(commits.map((commit) => [commit.sha, commit])); + const inputIndex = new Map(commits.map((commit, index) => [commit.sha, index])); + const children = new Map>(); + const remainingParents = new Map(); + for (const commit of commits) { + const parents = (commit.parents ?? commit.parentIds ?? []).filter((parent) => + bySha.has(parent), + ); + remainingParents.set(commit.sha, parents.length); + for (const parent of parents) { + const siblings = children.get(parent) ?? []; + siblings.push(commit.sha); + children.set(parent, siblings); + } + } + const ready = commits.filter((commit) => remainingParents.get(commit.sha) === 0); + const result: Array = []; + while (ready.length > 0) { + ready.sort((first, second) => inputIndex.get(first.sha)! - inputIndex.get(second.sha)!); + const commit = ready.shift()!; + result.push(commit); + for (const childSha of children.get(commit.sha) ?? []) { + const remaining = (remainingParents.get(childSha) ?? 1) - 1; + remainingParents.set(childSha, remaining); + if (remaining === 0) { + ready.push(bySha.get(childSha)!); + } + } + } + // A malformed/cyclic response should remain reviewable rather than dropping commits. + return result.length === commits.length + ? result + : [...result, ...commits.filter((commit) => !result.some((entry) => entry.sha === commit.sha))]; +}; + +const descriptionListsCommits = (description: string, commits: ReadonlyArray) => { + if (!description.trim() || commits.length < 2) { + return false; + } + const shortShas = new Set(commits.map((commit) => commit.shortSha.toLowerCase())); + const fullShas = new Set(commits.map((commit) => commit.sha.toLowerCase())); + const matches = description.toLowerCase().match(shortShaPattern) ?? []; + const matched = new Set( + matches.filter( + (value) => + shortShas.has(value) || + fullShas.has(value) || + [...fullShas].some((sha) => sha.startsWith(value)), + ), + ); + if (matched.size >= 2) { + return true; + } + const numberedLines = description + .split('\n') + .map((line) => line.trim()) + .filter((line) => numberedStepPattern.test(line)); + if (numberedLines.length < 2) { + return false; + } + const subjectHits = numberedLines.filter((line) => + commits.some((commit) => + line.toLowerCase().includes(commit.subject.toLowerCase().slice(0, 24)), + ), + ).length; + return subjectHits >= 2; +}; + +const looksChapterShaped = (commits: ReadonlyArray) => { + if (commits.length < 2) { + return false; + } + const intentional = commits.filter( + (commit) => + commit.role === 'feature' || + commit.role === 'refactor' || + commit.role === 'test' || + commit.role === 'docs' || + conventionalCommitPattern.test(commit.subject) || + numberedStepPattern.test(commit.subject), + ); + const uniqueSubjects = new Set(commits.map((commit) => commit.subject.toLowerCase())); + // GitLab commit subjects are often descriptive without using Conventional + // Commits. Treat a short, distinct, non-fixup history as chapter-shaped too. + return ( + uniqueSubjects.size >= 2 && + (intentional.length >= Math.ceil(commits.length * 0.5) || + uniqueSubjects.size === commits.length) + ); +}; + +export const classifyMergeRequestReviewStrategy = (input: { + commits: ReadonlyArray; + description?: string; + title?: string; +}): MergeRequestReviewStrategy => { + const classified = input.commits.map(classifyGitLabCommit); + const nonMerge = classified.filter((commit) => !commit.isMerge); + const text = `${input.title ?? ''}\n${input.description ?? ''}`; + const fixupDensity = + nonMerge.filter((commit) => commit.role === 'fixup' || commit.role === 'review-response') + .length / Math.max(nonMerge.length, 1); + + if (explicitWholePattern.test(text) && !explicitCommitByCommitPattern.test(text)) { + return { confidence: 0.95, mode: 'whole-mr', reason: 'explicit-whole' }; + } + if (explicitCommitByCommitPattern.test(text) || descriptionListsCommits(text, nonMerge)) { + return { + commits: nonMerge, + confidence: 0.95, + mode: 'commit-by-commit', + reason: 'explicit-description', + }; + } + if (nonMerge.length <= 1) { + return { confidence: 0.99, mode: 'whole-mr', reason: 'single-commit' }; + } + if (fixupDensity >= 0.4) { + const reviewResponseOnly = + nonMerge.filter((commit) => commit.role === 'review-response').length / + Math.max(nonMerge.length, 1) >= + 0.4; + return { + confidence: 0.85, + mode: 'whole-mr', + reason: reviewResponseOnly ? 'review-response-style' : 'fixup-style', + }; + } + if (nonMerge.length > 20) { + return { confidence: 0.7, mode: 'whole-mr', reason: 'too-many-commits' }; + } + if (looksChapterShaped(nonMerge)) { + return { + commits: nonMerge, + confidence: 0.75, + mode: 'commit-by-commit', + reason: conventionalCommitPattern.test(nonMerge[0]?.subject ?? '') + ? 'stacked-subjects' + : 'chapter-shaped', + }; + } + return { confidence: 0.55, mode: 'whole-mr', reason: 'default' }; +}; + +export const reviewStructureFromStrategy = ( + strategy: MergeRequestReviewStrategy, +): 'commit-by-commit' | 'whole-mr' => strategy.mode; + +export const overrideMergeRequestReviewStrategy = ( + strategy: MergeRequestReviewStrategy, + mode: 'commit-by-commit' | 'whole-mr', + sourceCommits: ReadonlyArray = [], +): MergeRequestReviewStrategy => { + if (mode === 'whole-mr') { + return { + confidence: 1, + mode: 'whole-mr', + reason: 'user-override', + }; + } + const commits = + strategy.mode === 'commit-by-commit' + ? strategy.commits + : sourceCommits.map(classifyGitLabCommit).filter((commit) => !commit.isMerge); + return { + commits, + confidence: 1, + mode: 'commit-by-commit', + reason: 'user-override', + }; +}; + +/** Cache identity segment for a version-comparison walkthrough. */ +export const versionCompareReviewStructureKey = ( + fromId: string, + toId: string, + structure: 'commit-by-commit' | 'whole-diff' = 'whole-diff', +) => `version-compare:${fromId}:${toId}:${structure}`; diff --git a/gitlab/src/transport.ts b/gitlab/src/transport.ts new file mode 100644 index 00000000..02b9341b --- /dev/null +++ b/gitlab/src/transport.ts @@ -0,0 +1,139 @@ +/** + * Host-injected GitLab transport. + * + * The host authenticates and executes HTTP. This package owns endpoint + * construction, pagination policy, and response parsing. + */ +export type GitLabTransport = { + request(request: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + query?: Readonly>; + body?: unknown; + }): Promise; + /** + * Optional paginated reader. When omitted, {@link request} is called with + * `page` / `per_page` until a short page is returned. + */ + requestPages?(request: { + path: string; + query?: Readonly>; + }): Promise>; + /** Optional raw text reader for repository file blobs. */ + requestText?(request: { + path: string; + query?: Readonly>; + }): Promise; +}; + +export type FakeGitLabTransportRoute = { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + query?: Readonly>; + response: unknown | ((request: { + method: string; + path: string; + query?: Readonly>; + }) => unknown | Promise); + text?: string | ((request: { + method: string; + path: string; + query?: Readonly>; + }) => string | Promise); +}; + +const queryKey = (query?: Readonly>) => + query + ? Object.entries(query) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&') + : ''; + +/** + * Deterministic transport for package tests. + */ +export const createFakeGitLabTransport = ( + routes: ReadonlyArray, +): GitLabTransport & { calls: Array<{ method: string; path: string; query?: Record }> } => { + const calls: Array<{ + method: string; + path: string; + query?: Record; + }> = []; + + const matchRoute = (method: string, path: string, query?: Readonly>) => { + const key = queryKey(query); + return ( + routes.find( + (route) => + route.path === path && + (route.method ?? 'GET') === method && + queryKey(route.query) === key, + ) ?? + routes.find( + (route) => route.path === path && (route.method ?? 'GET') === method && route.query == null, + ) + ); + }; + + return { + calls, + async request(request) { + const method = request.method ?? 'GET'; + calls.push({ + method, + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + const route = matchRoute(method, request.path, request.query); + if (!route) { + throw new Error(`No fake GitLab route for ${method} ${request.path}?${queryKey(request.query)}`); + } + const response = + typeof route.response === 'function' + ? await route.response({ method, path: request.path, query: request.query }) + : route.response; + return response as never; + }, + async requestPages(request) { + // Collect all pages if fake routes include page query variants; else one shot. + const values: Array = []; + let page = 1; + while (page < 50) { + const pageQuery = { ...(request.query ?? {}), page, per_page: 100 }; + const route = matchRoute('GET', request.path, pageQuery) ?? (page === 1 ? matchRoute('GET', request.path, request.query) : undefined); + if (!route) { + break; + } + calls.push({ method: 'GET', path: request.path, query: pageQuery }); + const response = + typeof route.response === 'function' + ? await route.response({ method: 'GET', path: request.path, query: pageQuery }) + : route.response; + const pageValues = Array.isArray(response) ? response : []; + values.push(...pageValues); + if (pageValues.length < 100) { + break; + } + page += 1; + } + return values; + }, + async requestText(request) { + calls.push({ + method: 'GET', + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + const route = matchRoute('GET', request.path, request.query); + if (!route || route.text == null) { + throw new Error(`No fake GitLab text route for GET ${request.path}`); + } + return typeof route.text === 'function' + ? route.text({ method: 'GET', path: request.path, query: request.query }) + : route.text; + }, + }; +}; diff --git a/gitlab/src/version-commit-evolution.ts b/gitlab/src/version-commit-evolution.ts new file mode 100644 index 00000000..c2efd218 --- /dev/null +++ b/gitlab/src/version-commit-evolution.ts @@ -0,0 +1,29 @@ +/** + * Re-export forge-neutral commit-stack evolution from Core. + * Kept as a stable GitLab package path for existing imports. + */ +export { + attributeRebaseDrivers, + createCommitPatchSignature, + matchVersionCommitStacks, + recommendVersionWalkthroughStructure, + scoreBaseCommitAsRebaseDriver, + toVersionCommitSummary, + versionCommitDiffConcurrency, + versionCommitEvolutionAlgorithmVersion, + versionCommitSignatureAlgorithmVersion, + versionCommitStackLimit, + type CommitPatchSignature, + type CommitStackEvolution, + type CommitStackEvolutionRange, + type DiffEndpointRef, + type VersionCommitEvolutionUnit, + type VersionCommitMatchKind, + type VersionCommitSummary, + type VersionRebaseDriverCommit, +} from '@nkzw/codiff-core'; + +import type { CommitStackEvolution } from '@nkzw/codiff-core'; + +/** @deprecated Prefer CommitStackEvolution. */ +export type MergeRequestVersionCommitEvolution = CommitStackEvolution; diff --git a/gitlab/src/version-compare.ts b/gitlab/src/version-compare.ts new file mode 100644 index 00000000..2d660537 --- /dev/null +++ b/gitlab/src/version-compare.ts @@ -0,0 +1,1341 @@ +// Merge-request version-comparison algorithm. +// Orchestration entry: computeVersionComparePreferringReplay() +// → materializeRebaseReplayTrees + computeRebaseReplayVersionCompare (preferred) +// → computeApproximatePatchTextVersionCompare (fallback when blobs missing) +// GitLab I/O + endpoint resolution live in merge-request.ts (fetchGitLabMergeRequestVersionCompare). +// Fate query: gitLabMergeRequestVersionCompare in server/src/fate/server.ts. +// See PLAN.md §G2. + +// Version-comparison control flow inspired by Jujutsu (jj) rebase_to_dest_parent + +// show_inter_diff (Apache-2.0). Clean-room TypeScript reimplementation; +// no jj source is vendored. + +// === Localized line diff (bounded Myers O(ND) algorithm) === + +const MAX_DIFF_D = 1000; + +type DiffEdit = { + kind: 'delete' | 'equal' | 'insert'; + line: string; +}; + +/** + * Compute the shortest edit script between two line arrays using Myers' O(ND) algorithm. + * Returns null if the edit distance exceeds maxD (pathological input). + */ +const myersEditScript = ( + a: ReadonlyArray, + b: ReadonlyArray, + maxD = MAX_DIFF_D, +): ReadonlyArray | null => { + const N = a.length; + const M = b.length; + + if (N === 0 && M === 0) { + return []; + } + if (N === 0) { + return b.map((line) => ({ kind: 'insert' as const, line })); + } + if (M === 0) { + return a.map((line) => ({ kind: 'delete' as const, line })); + } + // Minimum possible edit distance is |N-M|; bail early if already too large. + if (Math.abs(N - M) > maxD) { + return null; + } + + const MAX = Math.min(N + M, maxD); + const offset = MAX; + const size = 2 * MAX + 1; + const v = new Int32Array(size); + const trace: Array = []; + let solved = false; + + for (let d = 0; d <= MAX; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x: number; + if (k === -d || (k !== d && v[k - 1 + offset]! < v[k + 1 + offset]!)) { + x = v[k + 1 + offset]!; + } else { + x = v[k - 1 + offset]! + 1; + } + let y = x - k; + while (x < N && y < M && a[x] === b[y]) { + x++; + y++; + } + v[k + offset] = x; + if (x >= N && y >= M) { + solved = true; + break; + } + } + if (solved) { + break; + } + } + + if (!solved) { + return null; + } + + // Backtrack to build edit script. + let x = N; + let y = M; + const edits: Array = []; + + for (let d = trace.length - 1; d > 0; d--) { + const vPrev = trace[d]!; + const k = x - y; + + let prevK: number; + if (k === -d || (k !== d && vPrev[k - 1 + offset]! < vPrev[k + 1 + offset]!)) { + prevK = k + 1; + } else { + prevK = k - 1; + } + + const prevX = vPrev[prevK + offset]!; + const prevY = prevX - prevK; + + // Diagonal (equal) moves from after-edit position to current (x, y). + const afterEditX = prevK === k + 1 ? prevX : prevX + 1; + while (x > afterEditX) { + x--; + y--; + edits.push({ kind: 'equal', line: a[x]! }); + } + + // The edit itself. + if (prevK === k + 1) { + edits.push({ kind: 'insert', line: b[prevY]! }); + } else { + edits.push({ kind: 'delete', line: a[prevX]! }); + } + + x = prevX; + y = prevY; + } + + // Initial snake (d=0): remaining diagonals from (0,0). + while (x > 0) { + x--; + y--; + edits.push({ kind: 'equal', line: a[x]! }); + } + + edits.reverse(); + return edits; +}; + +/** + * Format an edit script as unified diff hunks with limited context lines. + * Returns an empty string when the edit script contains no changes. + */ +const formatUnifiedHunks = ( + edits: ReadonlyArray, + leftEndsWithNewline: boolean, + rightEndsWithNewline: boolean, + contextLines = 3, +): string => { + // Find positions of changes. + const changePositions: Array = []; + for (let i = 0; i < edits.length; i++) { + if (edits[i]!.kind !== 'equal') { + changePositions.push(i); + } + } + if (changePositions.length === 0) { + return ''; + } + + // Group changes into hunk ranges [start, end) with context. + const hunkRanges: Array<[number, number]> = []; + let hunkStart = Math.max(0, changePositions[0]! - contextLines); + let hunkEnd = changePositions[0]! + 1; + + for (let i = 1; i < changePositions.length; i++) { + const pos = changePositions[i]!; + if (pos <= hunkEnd + 2 * contextLines) { + hunkEnd = pos + 1; + } else { + hunkRanges.push([hunkStart, Math.min(edits.length, hunkEnd + contextLines)]); + hunkStart = Math.max(0, pos - contextLines); + hunkEnd = pos + 1; + } + } + hunkRanges.push([hunkStart, Math.min(edits.length, hunkEnd + contextLines)]); + + // Pre-compute old/new line numbers at each edit position. + const oldLineAt: Array = []; + const newLineAt: Array = []; + let oln = 1; + let nln = 1; + for (let i = 0; i < edits.length; i++) { + oldLineAt.push(oln); + newLineAt.push(nln); + const kind = edits[i]!.kind; + if (kind === 'equal' || kind === 'delete') { + oln++; + } + if (kind === 'equal' || kind === 'insert') { + nln++; + } + } + const totalOldLines = oln - 1; + const totalNewLines = nln - 1; + + // Format each hunk. + const output: Array = []; + for (const [start, end] of hunkRanges) { + const hunkEdits = edits.slice(start, end); + const oldStart = oldLineAt[start]!; + const newStart = newLineAt[start]!; + let oldCount = 0; + let newCount = 0; + const body: Array = []; + + for (let i = 0; i < hunkEdits.length; i++) { + const edit = hunkEdits[i]!; + if (edit.kind === 'equal') { + body.push(` ${edit.line}`); + oldCount++; + newCount++; + // Check for no-newline marker on the last line of both files. + if ( + oldLineAt[start + i]! === totalOldLines && + newLineAt[start + i]! === totalNewLines && + !leftEndsWithNewline && + !rightEndsWithNewline + ) { + body.push(String.raw`\ No newline at end of file`); + } + } else if (edit.kind === 'delete') { + body.push(`-${edit.line}`); + oldCount++; + // No-newline marker for last old line. + if (oldLineAt[start + i]! === totalOldLines && !leftEndsWithNewline) { + body.push(String.raw`\ No newline at end of file`); + } + } else { + body.push(`+${edit.line}`); + newCount++; + // No-newline marker for last new line. + if (newLineAt[start + i]! === totalNewLines && !rightEndsWithNewline) { + body.push(String.raw`\ No newline at end of file`); + } + } + } + + output.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); + output.push(...body); + } + + return output.join('\n') + '\n'; +}; + +/** + * Compute a localized unified diff between two file contents. + * Returns `{ patchBody, incomplete }`. + * `incomplete` is true when the edit distance exceeds the bounded cap, + * in which case `patchBody` is empty and the caller should fall back. + */ +export const computeLineDiff = ( + left: string, + right: string, + contextLines = 3, +): { incomplete: boolean; patchBody: string } => { + if (left === right) { + return { incomplete: false, patchBody: '' }; + } + + const leftLines = left.length === 0 ? [] : left.replace(/\n$/, '').split('\n'); + const rightLines = right.length === 0 ? [] : right.replace(/\n$/, '').split('\n'); + + const editScript = myersEditScript(leftLines, rightLines); + if (!editScript) { + return { incomplete: true, patchBody: '' }; + } + + const leftEndsWithNewline = left.length > 0 && left.endsWith('\n'); + const rightEndsWithNewline = right.length > 0 && right.endsWith('\n'); + const patchBody = formatUnifiedHunks( + editScript, + leftEndsWithNewline, + rightEndsWithNewline, + contextLines, + ); + + return { incomplete: false, patchBody }; +}; + +// === Bounded concurrency pool === + +const poolMap = async ( + items: ReadonlyArray, + concurrency: number, + fn: (item: T) => Promise, +): Promise => { + let nextIndex = 0; + const worker = async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + await fn(items[index]!); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); +}; + +import type { ChangedFile } from '@nkzw/codiff-core/types'; + +export type MergeRequestVersionRef = { + baseSha: string; + createdAt: string; + headSha: string; + id: string; + label: string; + startSha: string; +}; + +export type VersionCompareEndpoint = + | { kind: 'mr-base' } + | { commentId: string; kind: 'comment-position' } + | { baseSha: string; headSha: string; kind: 'diff-identity'; startSha: string } + | { headSha: string; kind: 'head-sha' } + | { kind: 'last-reviewed' } + | { kind: 'mr-version'; versionId: string }; + +export type VersionCompareRange = { + from: MergeRequestVersionRef; + paths?: ReadonlyArray; + to: MergeRequestVersionRef; +}; + +export type VersionCompareHunkClass = + | 'comment-anchored' + | 'conflict-resolution' + | 'intentional' + | 'rebase-noise'; + +export type VersionCompareFile = { + classes: ReadonlyArray; + file: ChangedFile; + oldPath?: string; + path: string; + relatedCommentIds: ReadonlyArray; + status: 'added' | 'deleted' | 'modified' | 'renamed' | 'unchanged-noise'; +}; + +export type VersionBaseRef = { + committedAt: string | null; + sha: string; + shortSha: string; + webUrl?: string; +}; + +export type VersionBaseMovementCommit = { + authoredAt: string; + authorName: string; + sha: string; + shortSha: string; + subject: string; + webUrl: string; +}; + +export type VersionBaseMovement = { + changed: boolean; + commits: ReadonlyArray; + commitsBetween: number | null; + commitTimestampDeltaMs: number | null; + diffStat: { + additions: number; + deletions: number; + filesChanged: number; + } | null; + from: VersionBaseRef; + relationship: 'forward' | 'backward' | 'divergent' | 'unknown'; + to: VersionBaseRef; + truncated: boolean; + warning?: string; +}; + +export type MergeRequestVersionCompare = { + algorithm: 'approximate-patch-text' | 'jj-rebase-replay'; + baseMovement?: VersionBaseMovement; + commentAssociations: ReadonlyArray<{ + commentId: string; + filePath?: string; + status: 'newly-anchored' | 'outdated' | 'resolved-by-change' | 'still-valid'; + }>; + files: ReadonlyArray; + range: VersionCompareRange; + summary: { + addedLines: number; + baseMoved: boolean; + commentsAffected: number; + conflictFiles: number; + deletedLines: number; + empty: boolean; + filesChanged: number; + intentionalFiles: number; + noiseFiles: number; + }; + warnings?: ReadonlyArray; +}; + +const getVersionCompareLineStats = (files: ReadonlyArray) => { + let addedLines = 0; + let deletedLines = 0; + for (const file of files) { + for (const section of file.file.sections) { + for (const line of section.patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + addedLines += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletedLines += 1; + } + } + } + } + return { addedLines, deletedLines }; +}; + +export type VersionPatchFile = { + newPath: string; + oldPath: string; + patchBody: string; + status: 'added' | 'deleted' | 'modified' | 'renamed'; +}; + +export type CommentAnchor = { + commentId: string; + filePath: string; + lineNumber?: number; + position: { + baseSha: string; + headSha: string; + startSha: string; + }; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const hashString = (value: string) => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash.toString(16); +}; + +const normalizePatchBody = (patchBody: string) => + patchBody + .split('\n') + .filter((line) => line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) + .map((line) => line.replace(/^./, (prefix) => prefix)) + .join('\n') + .trim(); + +const changeRegionFingerprint = (patchBody: string) => { + const changes = patchBody + .split('\n') + .filter( + (line) => + (line.startsWith('+') || line.startsWith('-')) && + !line.startsWith('+++') && + !line.startsWith('---'), + ) + .map((line) => line.slice(1).trimEnd()) + .filter(Boolean); + return hashString(changes.join('\n')); +}; + +const createChangedFile = ( + path: string, + oldPath: string | undefined, + status: ChangedFile['status'], + patchBody: string, + headSha: string, + kind: 'version-compare' | 'conflict' = 'version-compare', +): ChangedFile => { + const sectionId = `${path}:commit:version-compare`; + const headerOld = status === 'added' ? '/dev/null' : `a/${oldPath ?? path}`; + const headerNew = status === 'deleted' ? '/dev/null' : `b/${path}`; + const patch = `diff --git a/${oldPath ?? path} b/${path}\n--- ${headerOld}\n+++ ${headerNew}\n${patchBody}${ + patchBody.endsWith('\n') ? '' : '\n' + }`; + return { + fingerprint: `${headSha}:${kind}:${status}:${oldPath ?? path}:${path}:${patch.length}`, + ...(oldPath && oldPath !== path ? { oldPath } : {}), + path, + sections: [ + { + binary: false, + id: sectionId, + kind: 'commit', + loadState: 'ready', + patch, + }, + ], + status, + }; +}; + +const pathKey = (file: VersionPatchFile) => file.newPath || file.oldPath; + +const pairFiles = ( + fromFiles: ReadonlyArray, + toFiles: ReadonlyArray, +) => { + const fromByPath = new Map(fromFiles.map((file) => [pathKey(file), file])); + const toByPath = new Map(toFiles.map((file) => [pathKey(file), file])); + const paths = new Set([...fromByPath.keys(), ...toByPath.keys()]); + return [...paths] + .toSorted((first, second) => first.localeCompare(second)) + .map((path) => ({ + from: fromByPath.get(path) ?? null, + path, + to: toByPath.get(path) ?? null, + })); +}; + +type PatchRegion = { + body: string; + newEnd: number; + newStart: number; + oldEnd: number; + oldStart: number; +}; + +const patchRegions = (patch: string): ReadonlyArray => { + const matches = [...patch.matchAll(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@.*$/gm)]; + return matches.map((match, index) => { + const oldStart = Number(match[1]); + const oldCount = Number(match[2] ?? 1); + const newStart = Number(match[3]); + const newCount = Number(match[4] ?? 1); + return { + body: patch.slice(match.index! + match[0].length, matches[index + 1]?.index ?? patch.length), + newEnd: newStart + Math.max(0, newCount - 1), + newStart, + oldEnd: oldStart + Math.max(0, oldCount - 1), + oldStart, + }; + }); +}; + +const regionAtLine = (patch: string, lineNumber: number) => + patchRegions(patch).find( + (region) => + (lineNumber >= region.newStart - 2 && lineNumber <= region.newEnd + 2) || + (lineNumber >= region.oldStart - 2 && lineNumber <= region.oldEnd + 2), + ); + +const commentRegionChanged = ( + fromPatch: string | undefined, + toPatch: string | undefined, + lineNumber: number, +) => { + const before = fromPatch ? regionAtLine(fromPatch, lineNumber) : undefined; + const after = toPatch ? regionAtLine(toPatch, lineNumber) : undefined; + if (!before && !after) { + return false; + } + if (before && !after && toPatch) { + const beforeBody = normalizePatchBody(before.body); + return !patchRegions(toPatch).some( + (candidate) => normalizePatchBody(candidate.body) === beforeBody, + ); + } + if (after && !before && fromPatch) { + const afterBody = normalizePatchBody(after.body); + return !patchRegions(fromPatch).some( + (candidate) => normalizePatchBody(candidate.body) === afterBody, + ); + } + if (!before || !after) { + return true; + } + return normalizePatchBody(before.body) !== normalizePatchBody(after.body); +}; + +const commentContentWindowChanged = ( + before: string | undefined, + after: string | undefined, + lineNumber: number, +) => { + if (before == null || after == null) { + return before !== after; + } + const start = Math.max(0, lineNumber - 3); + const end = lineNumber + 2; + return ( + before.split('\n').slice(start, end).join('\n') !== + after.split('\n').slice(start, end).join('\n') + ); +}; + +const classifyCommentAssociations = ( + comments: ReadonlyArray, + intentionalPaths: ReadonlySet, + from: MergeRequestVersionRef, + to: MergeRequestVersionRef, + addressedCommentIds: ReadonlySet = new Set(), +) => + comments.map((comment) => { + const onFrom = + comment.position.headSha === from.headSha || + comment.position.baseSha === from.baseSha || + comment.position.startSha === from.startSha; + const pathTouched = intentionalPaths.has(comment.filePath); + if (!onFrom) { + return { + commentId: comment.commentId, + filePath: comment.filePath, + status: pathTouched ? ('newly-anchored' as const) : ('still-valid' as const), + }; + } + if (addressedCommentIds.has(comment.commentId)) { + return { + commentId: comment.commentId, + filePath: comment.filePath, + status: 'resolved-by-change' as const, + }; + } + if (pathTouched) { + return { + commentId: comment.commentId, + filePath: comment.filePath, + status: 'outdated' as const, + }; + } + if (comment.position.headSha === to.headSha) { + return { + commentId: comment.commentId, + filePath: comment.filePath, + status: 'still-valid' as const, + }; + } + return { + commentId: comment.commentId, + filePath: comment.filePath, + status: 'still-valid' as const, + }; + }); + +/** + * Approximate jj version comparison when only patch text is available: + * compare the logical change regions of each version's MR patch. + * Pure rebases (identical change regions) produce an empty intentional set. + */ +export const computeApproximatePatchTextVersionCompare = ({ + comments = [], + from, + fromFiles, + paths, + to, + toFiles, +}: { + comments?: ReadonlyArray; + from: MergeRequestVersionRef; + fromFiles: ReadonlyArray; + paths?: ReadonlyArray; + to: MergeRequestVersionRef; + toFiles: ReadonlyArray; +}): MergeRequestVersionCompare => { + const pathFilter = paths?.length ? new Set(paths) : null; + const pairs = pairFiles(fromFiles, toFiles).filter( + (pair) => pathFilter == null || pathFilter.has(pair.path), + ); + const files: Array = []; + const warnings: Array = []; + const addressedCommentIds = new Set(); + const baseMoved = from.baseSha !== to.baseSha; + + for (const pair of pairs) { + for (const comment of comments) { + if ( + comment.filePath === pair.path && + comment.lineNumber != null && + commentRegionChanged(pair.from?.patchBody, pair.to?.patchBody, comment.lineNumber) + ) { + addressedCommentIds.add(comment.commentId); + } + } + if (!pair.from && pair.to) { + files.push({ + classes: ['intentional'], + file: createChangedFile( + pair.to.newPath, + pair.to.oldPath, + pair.to.status, + pair.to.patchBody, + to.headSha, + ), + oldPath: pair.to.oldPath !== pair.to.newPath ? pair.to.oldPath : undefined, + path: pair.path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === pair.path) + .map((comment) => comment.commentId), + status: pair.to.status, + }); + continue; + } + if (pair.from && !pair.to) { + files.push({ + classes: ['intentional'], + file: createChangedFile( + pair.from.newPath, + pair.from.oldPath, + 'deleted', + pair.from.patchBody, + to.headSha, + ), + oldPath: pair.from.oldPath !== pair.from.newPath ? pair.from.oldPath : undefined, + path: pair.path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === pair.path) + .map((comment) => comment.commentId), + status: 'deleted', + }); + continue; + } + if (!pair.from || !pair.to) { + continue; + } + + const fromFingerprint = changeRegionFingerprint(pair.from.patchBody); + const toFingerprint = changeRegionFingerprint(pair.to.patchBody); + if (fromFingerprint === toFingerprint) { + // Pure rebase / identical logical patch — hide by default. + continue; + } + + // Prefer the newer version's patch body as the visible versionCompare surface. + // When both exist and differ, mark intentional; if markers look conflicted, + // flag conflict-resolution. + const conflictLike = + pair.to.patchBody.includes('<<<<<<<') || + pair.to.patchBody.includes('>>>>>>>') || + pair.from.patchBody.includes('<<<<<<<'); + const classes: Array = conflictLike + ? ['conflict-resolution', 'intentional'] + : ['intentional']; + if ( + baseMoved && + normalizePatchBody(pair.from.patchBody) === normalizePatchBody(pair.to.patchBody) + ) { + // Safety net: identical normalized text after base move. + continue; + } + if (baseMoved && fromFingerprint !== toFingerprint && !conflictLike) { + // Approximate path only — note degraded fidelity for consumers. + warnings.push( + `Approximate version comparison for ${pair.path} (could not replay onto new base).`, + ); + } + files.push({ + classes, + file: createChangedFile( + pair.to.newPath, + pair.to.oldPath, + pair.to.status, + pair.to.patchBody, + to.headSha, + conflictLike ? 'conflict' : 'version-compare', + ), + oldPath: pair.to.oldPath !== pair.to.newPath ? pair.to.oldPath : undefined, + path: pair.path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === pair.path) + .map((comment) => comment.commentId), + status: pair.to.status, + }); + } + + const intentionalPaths = new Set( + files + .filter( + (file) => + file.classes.includes('intentional') || file.classes.includes('conflict-resolution'), + ) + .map((file) => file.path), + ); + const commentAssociations = classifyCommentAssociations( + comments, + intentionalPaths, + from, + to, + addressedCommentIds, + ); + const intentionalFiles = files.filter((file) => file.classes.includes('intentional')).length; + const conflictFiles = files.filter((file) => file.classes.includes('conflict-resolution')).length; + + return { + algorithm: 'approximate-patch-text', + commentAssociations, + files, + range: { + from, + ...(paths?.length ? { paths } : {}), + to, + }, + summary: { + ...getVersionCompareLineStats(files), + baseMoved, + commentsAffected: commentAssociations.filter((item) => item.status !== 'still-valid').length, + conflictFiles, + empty: files.length === 0, + filesChanged: files.length, + intentionalFiles, + noiseFiles: 0, + }, + ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}), + }; +}; + +/** + * jj-faithful control flow when left/right trees (path→content) are available: + * left = from tree when bases match, else apply from-patch onto to-base; + * versionCompare = diff(left, right=to tree). + */ +export const computeRebaseReplayVersionCompare = ({ + comments = [], + from, + fromTree, + paths, + to, + toTree, +}: { + comments?: ReadonlyArray; + from: MergeRequestVersionRef; + fromTree: ReadonlyMap; + paths?: ReadonlyArray; + to: MergeRequestVersionRef; + toTree: ReadonlyMap; +}): MergeRequestVersionCompare & { incompleteDiffPaths: ReadonlyArray } => { + const pathFilter = paths?.length ? new Set(paths) : null; + const allPaths = new Set([...fromTree.keys(), ...toTree.keys()]); + const files: Array = []; + const incompleteDiffPaths: Array = []; + const addressedCommentIds = new Set(); + const baseMoved = from.baseSha !== to.baseSha; + + for (const path of [...allPaths].toSorted((a, b) => a.localeCompare(b))) { + if (pathFilter && !pathFilter.has(path)) { + continue; + } + const left = fromTree.get(path); + const right = toTree.get(path); + if (left === right) { + continue; + } + for (const comment of comments) { + if ( + comment.filePath === path && + comment.lineNumber != null && + commentContentWindowChanged(left, right, comment.lineNumber) + ) { + addressedCommentIds.add(comment.commentId); + } + } + if (left == null && right != null) { + files.push({ + classes: ['intentional'], + file: createChangedFile( + path, + undefined, + 'added', + `@@ -0,0 +1,${right.split('\n').length} @@\n${right + .split('\n') + .map((line) => `+${line}`) + .join('\n')}\n`, + to.headSha, + ), + path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === path) + .map((comment) => comment.commentId), + status: 'added', + }); + continue; + } + if (left != null && right == null) { + files.push({ + classes: ['intentional'], + file: createChangedFile( + path, + path, + 'deleted', + `@@ -1,${left.split('\n').length} +0,0 @@\n${left + .split('\n') + .map((line) => `-${line}`) + .join('\n')}\n`, + to.headSha, + ), + path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === path) + .map((comment) => comment.commentId), + status: 'deleted', + }); + continue; + } + if (left == null || right == null) { + continue; + } + const conflictLike = left.includes('<<<<<<<') || right.includes('<<<<<<<'); + const diff = computeLineDiff(left, right); + if (diff.incomplete) { + incompleteDiffPaths.push(path); + continue; + } + if (!diff.patchBody) { + // computeLineDiff determined the files are identical after line splitting. + continue; + } + files.push({ + classes: conflictLike ? ['conflict-resolution', 'intentional'] : ['intentional'], + file: createChangedFile( + path, + path, + 'modified', + diff.patchBody, + to.headSha, + conflictLike ? 'conflict' : 'version-compare', + ), + path, + relatedCommentIds: comments + .filter((comment) => comment.filePath === path) + .map((comment) => comment.commentId), + status: 'modified', + }); + } + + const intentionalPaths = new Set(files.map((file) => file.path)); + const commentAssociations = classifyCommentAssociations( + comments, + intentionalPaths, + from, + to, + addressedCommentIds, + ); + + return { + algorithm: 'jj-rebase-replay', + commentAssociations, + files, + incompleteDiffPaths, + range: { + from, + ...(paths?.length ? { paths } : {}), + to, + }, + summary: { + ...getVersionCompareLineStats(files), + baseMoved, + commentsAffected: commentAssociations.filter((item) => item.status !== 'still-valid').length, + conflictFiles: files.filter((file) => file.classes.includes('conflict-resolution')).length, + empty: files.length === 0, + filesChanged: files.length, + intentionalFiles: files.length, + noiseFiles: 0, + }, + }; +}; + +export const versionCompareAlgorithmVersion = 'jj-rebase-replay-v4'; + +export type BlobLookup = (path: string, ref: string) => Promise | string | null; + +const parseHunkHeader = (line: string) => { + const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!match) { + return null; + } + return { + newCount: match[4] == null ? 1 : Number(match[4]), + newStart: Number(match[3]), + oldCount: match[2] == null ? 1 : Number(match[2]), + oldStart: Number(match[1]), + }; +}; + +/** Apply a unified-diff body (no git headers) onto source text. Returns conflict markers on mismatch. */ +export const applyUnifiedPatchBody = ( + source: string, + patchBody: string, +): { conflict: boolean; text: string } => { + const sourceLines = source.length === 0 ? [] : source.replace(/\n$/, '').split('\n'); + const patchLines = patchBody.replace(/\n$/, '').split('\n'); + const output: Array = []; + let sourceIndex = 0; + let cursor = 0; + let conflict = false; + + while (cursor < patchLines.length) { + const header = parseHunkHeader(patchLines[cursor] ?? ''); + if (!header) { + cursor += 1; + continue; + } + cursor += 1; + const oldStart = Math.max(0, header.oldStart - 1); + while (sourceIndex < oldStart && sourceIndex < sourceLines.length) { + output.push(sourceLines[sourceIndex] ?? ''); + sourceIndex += 1; + } + while (cursor < patchLines.length) { + const line = patchLines[cursor] ?? ''; + if (line.startsWith('@@')) { + break; + } + cursor += 1; + if (line.startsWith('\\')) { + continue; + } + const prefix = line[0] ?? ' '; + const content = line.slice(1); + if (prefix === ' ' || prefix === '-') { + const expected = sourceLines[sourceIndex]; + if (expected !== content) { + conflict = true; + // Materialize a conflict block and skip remaining hunk application for this file. + const remainingSource = sourceLines.slice(sourceIndex).join('\n'); + const remainingPatch = patchLines.slice(cursor - 1).join('\n'); + return { + conflict: true, + text: `${output.join('\n')}${output.length ? '\n' : ''}<<<<<<< source\n${remainingSource}\n=======\n${remainingPatch}\n>>>>>>> patch\n`, + }; + } + if (prefix === ' ') { + output.push(content); + } + sourceIndex += 1; + } else if (prefix === '+') { + output.push(content); + } + } + } + while (sourceIndex < sourceLines.length) { + output.push(sourceLines[sourceIndex] ?? ''); + sourceIndex += 1; + } + const text = output.join('\n'); + return { + conflict, + text: + source.endsWith('\n') || text.length === 0 + ? `${text}${text.length ? '\n' : ''}` + : `${text}\n`, + }; +}; + +const collectVersionComparePaths = ({ + comments = [], + fromFiles, + paths, + toFiles, +}: { + comments?: ReadonlyArray; + fromFiles: ReadonlyArray; + paths?: ReadonlyArray; + toFiles: ReadonlyArray; +}) => { + if (paths?.length) { + return [...new Set(paths)]; + } + return [ + ...new Set([ + ...fromFiles.map(pathKey), + ...toFiles.map(pathKey), + ...comments.map((comment) => comment.filePath), + ]), + ].toSorted((a, b) => a.localeCompare(b)); +}; + +/** + * Materialize left/right trees for jj-style rebase-then-diff. + * left = headA when bases match, else apply(from-patch, onto baseB). + * right = headB tree for the same path set. + */ +export const materializeRebaseReplayTrees = async ({ + from, + fromFiles, + paths, + readBlob, + to, + toFiles, +}: { + from: MergeRequestVersionRef; + fromFiles: ReadonlyArray; + paths?: ReadonlyArray; + readBlob: BlobLookup; + to: MergeRequestVersionRef; + toFiles: ReadonlyArray; +}): Promise<{ + fromTree: Map; + incompletePaths: Array; + toTree: Map; + warnings: Array; +}> => { + const targetPaths = collectVersionComparePaths({ fromFiles, paths, toFiles }); + const fromByPath = new Map(fromFiles.map((file) => [pathKey(file), file])); + const toByPath = new Map(toFiles.map((file) => [pathKey(file), file])); + const fromTree = new Map(); + const toTree = new Map(); + const incompletePaths: Array = []; + const warnings: Array = []; + const basesMatch = from.baseSha === to.baseSha; + + await poolMap(targetPaths, 8, async (path) => { + const fromFile = fromByPath.get(path); + const toFile = toByPath.get(path); + try { + const rightBlob = await readBlob(path, to.headSha); + if (rightBlob != null) { + toTree.set(path, rightBlob); + } else if (toFile?.status === 'deleted') { + // deleted in to → absent from right tree + } else if (toFile) { + // Reconstruct right from baseB + to-patch when head blob missing. + const toBaseBlob = (await readBlob(path, to.baseSha)) ?? ''; + if (toFile.status === 'added' && !toBaseBlob) { + const applied = applyUnifiedPatchBody('', toFile.patchBody); + if (applied.conflict) { + incompletePaths.push(path); + warnings.push(`Conflict reconstructing ${path} at head ${to.headSha.slice(0, 7)}.`); + return; + } + toTree.set(path, applied.text); + } else if (toBaseBlob || toFile.patchBody) { + const applied = applyUnifiedPatchBody(toBaseBlob, toFile.patchBody); + if (applied.conflict) { + incompletePaths.push(path); + warnings.push(`Conflict reconstructing ${path} at head ${to.headSha.slice(0, 7)}.`); + return; + } + toTree.set(path, applied.text); + } else { + incompletePaths.push(path); + return; + } + } + + if (basesMatch) { + const leftBlob = await readBlob(path, from.headSha); + if (leftBlob != null) { + fromTree.set(path, leftBlob); + } else if (fromFile?.status === 'deleted') { + // absent + } else if (fromFile) { + const fromBaseBlob = (await readBlob(path, from.baseSha)) ?? ''; + const applied = applyUnifiedPatchBody(fromBaseBlob, fromFile.patchBody); + if (applied.conflict) { + incompletePaths.push(path); + warnings.push(`Conflict replaying ${path} onto ${from.baseSha.slice(0, 7)}.`); + return; + } + fromTree.set(path, applied.text); + } else if (rightBlob != null) { + // Path only in to; left equals shared base/head-from content if present. + const shared = await readBlob(path, from.headSha); + if (shared != null) { + fromTree.set(path, shared); + } + } + } else { + // Rebase from-patch onto to.base. + const onto = (await readBlob(path, to.baseSha)) ?? ''; + if (fromFile) { + let replayOk = false; + if (fromFile.status === 'added' && !onto) { + const applied = applyUnifiedPatchBody('', fromFile.patchBody); + if (!applied.conflict) { + fromTree.set(path, applied.text); + replayOk = true; + } + } else { + const applied = applyUnifiedPatchBody(onto, fromFile.patchBody); + if (!applied.conflict) { + fromTree.set(path, applied.text); + replayOk = true; + } + } + if (!replayOk) { + // Replay produced a conflict — the base changed in a way that + // affects this file's patch. Fall back to the actual v11 head + // blob. The resulting diff (head-A vs head-B) may include some + // base-change noise alongside intentional changes, but is still + // localized and far more useful than dropping the path. + const headABlob = await readBlob(path, from.headSha); + if (headABlob != null) { + fromTree.set(path, headABlob); + warnings.push( + `Replay conflict for ${path} — comparing v${from.label} head directly (may include base changes).`, + ); + } else { + incompletePaths.push(path); + warnings.push(`Conflict replaying ${path} onto ${to.baseSha.slice(0, 7)}.`); + } + } + } else if (onto) { + // Unchanged in from MR; left is just the new base content. + fromTree.set(path, onto); + } + } + } catch (error) { + incompletePaths.push(path); + warnings.push( + `Missing blobs for ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }); + + return { fromTree, incompletePaths, toTree, warnings }; +}; + +/** + * Prefer jj rebase-replay when trees can be materialized; fall back per-file + * to approximate patch-text comparison for incomplete paths only. + */ +export const computeVersionComparePreferringReplay = async ({ + comments = [], + from, + fromFiles, + paths, + readBlob, + to, + toFiles, +}: { + comments?: ReadonlyArray; + from: MergeRequestVersionRef; + fromFiles: ReadonlyArray; + paths?: ReadonlyArray; + readBlob: BlobLookup; + to: MergeRequestVersionRef; + toFiles: ReadonlyArray; +}): Promise => { + const targetPaths = collectVersionComparePaths({ comments, fromFiles, paths, toFiles }); + const materialization = await materializeRebaseReplayTrees({ + from, + fromFiles, + paths, + readBlob, + to, + toFiles, + }); + const replayableCount = targetPaths.length - materialization.incompletePaths.length; + const canReplay = + targetPaths.length === 0 + ? false + : materialization.incompletePaths.length === 0 || + replayableCount >= Math.ceil(targetPaths.length / 2); + + if (canReplay && (materialization.fromTree.size > 0 || materialization.toTree.size > 0)) { + const replay = computeRebaseReplayVersionCompare({ + comments, + from, + fromTree: materialization.fromTree, + paths, + to, + toTree: materialization.toTree, + }); + const incomplete = new Set([...materialization.incompletePaths, ...replay.incompleteDiffPaths]); + if (incomplete.size === 0) { + return { + ...replay, + ...(materialization.warnings.length + ? { warnings: [...new Set([...(replay.warnings ?? []), ...materialization.warnings])] } + : {}), + }; + } + // Fill gaps with approximate for incomplete paths only. + const approx = computeApproximatePatchTextVersionCompare({ + comments, + from, + fromFiles: fromFiles.filter((file) => incomplete.has(pathKey(file))), + paths: [...incomplete], + to, + toFiles: toFiles.filter((file) => incomplete.has(pathKey(file))), + }); + const files = [...replay.files, ...approx.files].toSorted((a, b) => + a.path.localeCompare(b.path), + ); + const commentAssociations = comments.flatMap((comment) => { + const source = incomplete.has(comment.filePath) ? approx : replay; + const association = source.commentAssociations.find( + (candidate) => candidate.commentId === comment.commentId, + ); + return association ? [association] : []; + }); + return { + algorithm: 'jj-rebase-replay', + commentAssociations, + files, + range: { + from, + ...(paths?.length ? { paths } : {}), + to, + }, + summary: { + ...getVersionCompareLineStats(files), + baseMoved: from.baseSha !== to.baseSha, + commentsAffected: commentAssociations.filter((item) => item.status !== 'still-valid') + .length, + conflictFiles: files.filter((file) => file.classes.includes('conflict-resolution')).length, + empty: files.length === 0, + filesChanged: files.length, + intentionalFiles: files.filter((file) => file.classes.includes('intentional')).length, + noiseFiles: 0, + }, + warnings: [ + ...new Set([ + ...(replay.warnings ?? []), + ...(approx.warnings ?? []), + ...materialization.warnings, + ...[...incomplete].map( + (path) => + `Approximate version comparison for ${path} (could not replay onto new base).`, + ), + ]), + ], + }; + } + + const approx = computeApproximatePatchTextVersionCompare({ + comments, + from, + fromFiles, + paths, + to, + toFiles, + }); + return { + ...approx, + warnings: [ + ...new Set([ + ...(approx.warnings ?? []), + ...materialization.warnings, + 'Fell back to approximate patch-text version comparison (insufficient blobs for rebase-replay).', + ]), + ], + }; +}; + +export const isMergeRequestVersionRef = (value: unknown): value is MergeRequestVersionRef => { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.id === 'string' && + typeof value.baseSha === 'string' && + typeof value.startSha === 'string' && + typeof value.headSha === 'string' && + typeof value.createdAt === 'string' && + typeof value.label === 'string' + ); +}; diff --git a/gitlab/tsconfig.build.json b/gitlab/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/gitlab/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/gitlab/vite.config.ts b/gitlab/vite.config.ts new file mode 100644 index 00000000..ef7d6468 --- /dev/null +++ b/gitlab/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + dts: false, + }, +}); diff --git a/package.json b/package.json index a0cf2ef6..3d746647 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "type": "module", "main": "./electron/main.cjs", "scripts": { - "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", + "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", "codiff": "node ./bin/codiff.js", "dev": "vp dev --host 127.0.0.1", "dev:app": "ELECTRON_RENDERER_URL=http://127.0.0.1:5173 node ./bin/codiff.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 552c8262..f89224ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,15 +6,9 @@ settings: catalogs: default: - vite: - specifier: npm:@voidzero-dev/vite-plus-core@^0.2.4 - version: 0.2.4 vite-plus: specifier: ^0.2.4 version: 0.2.4 - vitest: - specifier: 4.1.10 - version: 4.1.10 overrides: extract-zip>yauzl: ^3.4.0 @@ -153,6 +147,12 @@ importers: specifier: ^1.4.2 version: 1.4.2(typescript@7.0.2) + gitlab: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + service: dependencies: '@nkzw/codiff-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7bd77c5a..ebafdf45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - core - service - web + - gitlab allowBuilds: '@google/genai': false diff --git a/vite.config.ts b/vite.config.ts index 2f5e327e..1bbdfe22 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,12 @@ import { defineConfig } from 'vite-plus'; const testWorkers = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 6))); export default defineConfig({ + // Added by jjk so Vite/Vitest does not watch jj internals (see jj FAQ). + server: { + watch: { + ignored: ['**/.jj/**'], + }, + }, base: './', build: { chunkSizeWarningLimit: 10 * 1024, @@ -107,6 +113,7 @@ export default defineConfig({ test: { include: [ 'core/**/*.test.{ts,tsx}', + 'gitlab/**/*.test.ts', 'electron/**/*.test.ts', 'service/**/*.test.ts', 'web/**/*.test.{ts,tsx}', From 9a36ff677965f49c8a2553b68a84563dfb29895c Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:48 -0500 Subject: [PATCH 07/20] Exercise GitLab comparison and evolution cases Add focused fixtures for pure rebases, edited rebases, conflict resolutions, strategy classification, and commit pairing. Keeping the high-volume scenario coverage separate makes the provider implementation reviewable while preserving regression coverage for the analysis behavior Codiff Web will depend on. --- gitlab/__tests__/review-strategy.test.ts | 160 +++++ .../version-commit-evolution.test.ts | 366 +++++++++++ gitlab/__tests__/version-compare.test.ts | 602 ++++++++++++++++++ gitlab/fixtures/version-compare-cases.ts | 83 +++ 4 files changed, 1211 insertions(+) create mode 100644 gitlab/__tests__/review-strategy.test.ts create mode 100644 gitlab/__tests__/version-commit-evolution.test.ts create mode 100644 gitlab/__tests__/version-compare.test.ts create mode 100644 gitlab/fixtures/version-compare-cases.ts diff --git a/gitlab/__tests__/review-strategy.test.ts b/gitlab/__tests__/review-strategy.test.ts new file mode 100644 index 00000000..1495a041 --- /dev/null +++ b/gitlab/__tests__/review-strategy.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from 'vite-plus/test'; +import { + classifyGitLabCommit, + classifyMergeRequestReviewStrategy, + versionCompareReviewStructureKey, + orderCommitsTopologically, + overrideMergeRequestReviewStrategy, + type GitLabMergeRequestCommitLike, +} from '../src/review-strategy.ts'; + +const commit = ( + overrides: Partial & + Pick, +): GitLabMergeRequestCommitLike => ({ + authoredDate: '2026-07-01T00:00:00.000Z', + authorName: 'Ada', + message: overrides.title, + parentIds: ['parent'], + shortSha: overrides.sha.slice(0, 8), + ...overrides, +}); + +test('classifies fixup and review-response subjects', () => { + expect(classifyGitLabCommit(commit({ sha: 'aaaaaaaa', title: 'fixup! add gate' })).role).toBe( + 'fixup', + ); + expect( + classifyGitLabCommit(commit({ sha: 'bbbbbbbb', title: 'Address review comments' })).role, + ).toBe('review-response'); + expect(classifyGitLabCommit(commit({ sha: 'cccccccc', title: 'feat: add gate' })).role).toBe( + 'feature', + ); +}); + +test('prefers whole-mr for single commits and fixup-heavy histories', () => { + expect( + classifyMergeRequestReviewStrategy({ + commits: [commit({ sha: 'aaaaaaaa', title: 'feat: only one' })], + description: '', + title: 'One change', + }), + ).toMatchObject({ mode: 'whole-mr', reason: 'single-commit' }); + + expect( + classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'feat: start' }), + commit({ sha: 'bbbbbbbb', title: 'fixup! start' }), + commit({ sha: 'cccccccc', title: 'Address review comments' }), + commit({ sha: 'dddddddd', title: 'nits' }), + ], + description: '', + title: 'WIP', + }), + ).toMatchObject({ mode: 'whole-mr', reason: 'fixup-style' }); +}); + +test('detects explicit commit-by-commit requests and chapter-shaped stacks', () => { + expect( + classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'feat: model' }), + commit({ sha: 'bbbbbbbb', title: 'test: cover model' }), + ], + description: 'Please review commit by commit.', + title: 'Stack', + }), + ).toMatchObject({ mode: 'commit-by-commit', reason: 'explicit-description' }); + + expect( + classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'feat: add endpoint' }), + commit({ sha: 'bbbbbbbb', title: 'test: cover endpoint' }), + commit({ sha: 'cccccccc', title: 'docs: document endpoint' }), + ], + description: '', + title: 'Endpoint stack', + }), + ).toMatchObject({ mode: 'commit-by-commit' }); +}); +test('treats a short distinct descriptive history as commit-by-commit', () => { + expect( + classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'Add the request path' }), + commit({ sha: 'bbbbbbbb', title: 'Handle retries' }), + ], + description: '', + title: 'Request handling', + }), + ).toMatchObject({ mode: 'commit-by-commit', reason: 'chapter-shaped' }); +}); + +test('honors explicit whole-mr language over chapter shape', () => { + expect( + classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'feat: a' }), + commit({ sha: 'bbbbbbbb', title: 'feat: b' }), + ], + description: 'Please review as a whole; squash on merge.', + title: 'Two features', + }), + ).toMatchObject({ mode: 'whole-mr', reason: 'explicit-whole' }); +}); + +test('supports user overrides for walkthrough structure', () => { + const base = classifyMergeRequestReviewStrategy({ + commits: [ + commit({ sha: 'aaaaaaaa', title: 'feat: a' }), + commit({ sha: 'bbbbbbbb', title: 'test: a' }), + ], + description: '', + title: 'Stack', + }); + expect(overrideMergeRequestReviewStrategy(base, 'whole-mr')).toMatchObject({ + mode: 'whole-mr', + reason: 'user-override', + }); + expect(overrideMergeRequestReviewStrategy(base, 'commit-by-commit')).toMatchObject({ + mode: 'commit-by-commit', + reason: 'user-override', + }); + const forcedCommitStrategy = overrideMergeRequestReviewStrategy( + { confidence: 1, mode: 'whole-mr', reason: 'default' }, + 'commit-by-commit', + [commit({ sha: 'cccccccc', title: 'Add routing' })], + ); + expect(forcedCommitStrategy).toMatchObject({ + commits: [expect.objectContaining({ sha: 'cccccccc' })], + mode: 'commit-by-commit', + reason: 'user-override', + }); +}); + +test('builds version-comparison walkthrough cache structure keys', () => { + expect(versionCompareReviewStructureKey('1', '2')).toBe('version-compare:1:2:whole-diff'); + expect(versionCompareReviewStructureKey('1', '2', 'commit-by-commit')).toBe( + 'version-compare:1:2:commit-by-commit', + ); +}); + +test('orders commits topologically from the merge-request base toward its head', () => { + const oldest = classifyGitLabCommit( + commit({ parentIds: ['base'], sha: 'aaaaaaaa', title: 'First' }), + ); + const middle = classifyGitLabCommit( + commit({ parentIds: [oldest.sha], sha: 'bbbbbbbb', title: 'Second' }), + ); + const newest = classifyGitLabCommit( + commit({ parentIds: [middle.sha], sha: 'cccccccc', title: 'Third' }), + ); + + expect(orderCommitsTopologically([newest, middle, oldest]).map((entry) => entry.sha)).toEqual([ + oldest.sha, + middle.sha, + newest.sha, + ]); +}); diff --git a/gitlab/__tests__/version-commit-evolution.test.ts b/gitlab/__tests__/version-commit-evolution.test.ts new file mode 100644 index 00000000..6be8a169 --- /dev/null +++ b/gitlab/__tests__/version-commit-evolution.test.ts @@ -0,0 +1,366 @@ +import type { ChangedFile } from '@nkzw/codiff-core/types'; +import { describe, expect, test } from 'vite-plus/test'; +import { + attributeRebaseDrivers, + createCommitPatchSignature, + matchVersionCommitStacks, + recommendVersionWalkthroughStructure, + scoreBaseCommitAsRebaseDriver, + type CommitPatchSignature, +} from '../src/version-commit-evolution.ts'; + +const endpoint = (id: string, headSha: string) => ({ + baseSha: `base-${id}`, + createdAt: '2026-07-15T00:00:00.000Z', + headSha, + id, + label: `v${id}`, + startSha: `base-${id}`, +}); + +const commit = (index: number, generation: 'new' | 'old' = 'old') => ({ + authoredDate: `2026-07-15T00:${String(index).padStart(2, '0')}:00.000Z`, + authorName: 'Ada', + message: `Change logical unit ${index}`, + parentIds: index === 1 ? [`${generation}-base`] : [`${generation}-${index - 1}`], + sha: `${generation}-${index}`, + shortSha: `${generation[0]}${index}`, + title: `Change logical unit ${index}`, + webUrl: `https://gitlab.example/commit/${generation}-${index}`, +}); + +const signature = ( + sha: string, + index: number, + patchId: string, + revision = 'same', +): CommitPatchSignature => ({ + additions: 10 + index, + changedPaths: [`src/unit-${index}.ts`], + changeTokenSketch: [`token-${index}`, `revision-${revision}`], + commitSha: sha, + deletions: index, + exactPatchId: patchId, + filesChanged: 1, + subjectKey: `change logical unit ${index}`, +}); + +const changedFile = (offset: number): ChangedFile => ({ + fingerprint: String(offset), + path: 'src/app.ts', + sections: [ + { + binary: false, + id: String(offset), + kind: 'commit', + loadState: 'ready', + patch: `diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -${offset},1 +${offset},1 @@\n-old value\n+new value\n`, + }, + ], + status: 'modified', +}); + +describe('version commit evolution', () => { + test('returns only revised logical commits 2, 4, and 6 as reviewable after a rebase', async () => { + const oldCommits = Array.from({ length: 10 }, (_, index) => commit(index + 1)); + const newCommits = Array.from({ length: 10 }, (_, index) => commit(index + 1, 'new')); + const revised = new Set([2, 4, 6]); + const signatures = new Map(); + for (let index = 1; index <= 10; index += 1) { + signatures.set( + `old-${index}`, + signature(`old-${index}`, index, `patch-${index}`, revised.has(index) ? 'old' : 'same'), + ); + signatures.set( + `new-${index}`, + signature( + `new-${index}`, + index, + revised.has(index) ? `revised-patch-${index}` : `patch-${index}`, + revised.has(index) ? 'new' : 'same', + ), + ); + } + const evolution = await matchVersionCommitStacks({ + from: endpoint('2', 'old-10'), + newCommits, + oldCommits, + signatures, + to: endpoint('6', 'new-10'), + }); + + expect( + evolution.units.filter((unit) => unit.reviewable).map((unit) => unit.before?.sha), + ).toEqual(['old-2', 'old-4', 'old-6']); + expect(evolution.summary).toMatchObject({ + reviewable: 3, + revised: 3, + rewrittenSamePatch: 7, + }); + expect(evolution.recommendation.structure).toBe('commit-by-commit'); + }); + + test('keeps duplicate patch IDs unmatched instead of forcing identity', async () => { + const oldCommits = [commit(1), commit(2)]; + const newCommits = [commit(1, 'new'), commit(2, 'new')]; + const signatures = new Map([ + ['old-1', signature('old-1', 1, 'duplicate')], + ['old-2', signature('old-2', 2, 'duplicate')], + ['new-1', signature('new-1', 1, 'duplicate')], + ['new-2', signature('new-2', 2, 'duplicate')], + ]); + const evolution = await matchVersionCommitStacks({ + from: endpoint('1', 'old-2'), + newCommits, + oldCommits, + signatures, + to: endpoint('2', 'new-2'), + }); + expect(evolution.summary.rewrittenSamePatch).toBe(0); + }); + + test('keeps commits unclassified when patch evidence or one stack is unavailable', async () => { + const oldCommits = [commit(1)]; + const newCommits = [commit(1, 'new')]; + const withoutPatches = await matchVersionCommitStacks({ + from: endpoint('1', 'old-1'), + newCommits, + oldCommits, + signatures: new Map(), + to: endpoint('2', 'new-1'), + }); + expect(withoutPatches.summary).toMatchObject({ added: 0, ambiguous: 2, removed: 0 }); + expect(withoutPatches.units.every((unit) => !unit.reviewable)).toBe(true); + + const withOnlyLaterStack = await matchVersionCommitStacks({ + from: endpoint('1', 'old-1'), + newCommits, + oldCommits: [], + signatures: new Map([['new-1', signature('new-1', 1, 'new-patch')]]), + stackCompleteness: { new: true, old: false }, + to: endpoint('2', 'new-1'), + }); + expect(withOnlyLaterStack.summary).toMatchObject({ added: 0, ambiguous: 1, removed: 0 }); + expect(withOnlyLaterStack.units[0]?.matchReasons).toContain( + 'Insufficient evidence to classify this commit as new', + ); + + const unrelatedNewCommit = { + ...newCommits[0]!, + authorName: 'Lin', + message: 'Replace an unrelated subsystem', + title: 'Replace an unrelated subsystem', + }; + const unrelatedNewSignature = { + ...signature('new-1', 1, 'unrelated-patch'), + additions: 1000, + changedPaths: ['unrelated/file.ts'], + changeTokenSketch: ['unrelated-token'], + subjectKey: 'replace an unrelated subsystem', + }; + const unrelatedChanges = await matchVersionCommitStacks({ + from: endpoint('1', 'old-1'), + newCommits: [unrelatedNewCommit], + oldCommits, + signatures: new Map([ + ['old-1', signature('old-1', 1, 'old-patch')], + ['new-1', unrelatedNewSignature], + ]), + to: endpoint('2', 'new-1'), + }); + expect(unrelatedChanges.summary).toMatchObject({ added: 1, ambiguous: 0, removed: 1 }); + }); + + test('classifies earlier MR commits rewritten into the later target base', async () => { + const oldCommits = [commit(1), commit(2), commit(3)]; + const newCommits = [commit(3, 'new'), commit(4, 'new'), commit(5, 'new')]; + const baseCommits = [ + { ...commit(1, 'new'), sha: 'base-1', shortSha: 'b1' }, + { ...commit(2, 'new'), sha: 'base-2', shortSha: 'b2' }, + ]; + const signatures = new Map([ + ['old-1', signature('old-1', 1, 'old-patch-1', 'old')], + ['old-2', signature('old-2', 2, 'old-patch-2', 'old')], + ['old-3', signature('old-3', 3, 'old-patch-3', 'old')], + ['base-1', signature('base-1', 1, 'base-patch-1', 'new')], + ['base-2', signature('base-2', 2, 'base-patch-2', 'new')], + ['new-3', signature('new-3', 3, 'new-patch-3', 'new')], + ['new-4', signature('new-4', 4, 'new-patch-4')], + ['new-5', signature('new-5', 5, 'new-patch-5')], + ]); + + const evolution = await matchVersionCommitStacks({ + baseCommits, + from: endpoint('1', 'old-3'), + newCommits, + oldCommits, + signatures, + to: endpoint('2', 'new-5'), + }); + + expect(evolution.summary).toMatchObject({ + absorbedIntoBase: 2, + added: 2, + ambiguous: 0, + reviewable: 3, + revised: 1, + }); + expect( + evolution.units + .filter((unit) => unit.kind === 'absorbed-into-base') + .map((unit) => [unit.before?.sha, unit.baseCommit?.sha]), + ).toEqual([ + ['old-1', 'base-1'], + ['old-2', 'base-2'], + ]); + expect(evolution.recommendation.structure).toBe('commit-by-commit'); + }); + + test('normalizes hunk offsets and stores only hashed changed-line tokens', async () => { + const first = await createCommitPatchSignature({ sha: 'a', title: 'Update app' }, [ + changedFile(1), + ]); + const second = await createCommitPatchSignature({ sha: 'b', title: 'Update app' }, [ + changedFile(100), + ]); + expect(first.exactPatchId).toBe(second.exactPatchId); + expect(JSON.stringify(first.changeTokenSketch)).not.toContain('value'); + }); + + test('recommends structure from content confidence without an upper commit-count bound', () => { + expect( + recommendVersionWalkthroughStructure({ ambiguous: 0, pairingCoverage: 1, reviewable: 1 }) + .structure, + ).toBe('whole-diff'); + expect( + recommendVersionWalkthroughStructure({ ambiguous: 0, pairingCoverage: 0.5, reviewable: 3 }) + .structure, + ).toBe('whole-diff'); + expect( + recommendVersionWalkthroughStructure({ ambiguous: 0, pairingCoverage: 1, reviewable: 30 }) + .structure, + ).toBe('commit-by-commit'); + }); +}); + +describe('rebase driver attribution', () => { + test('scores overlapping base commits as likely drivers', () => { + const unitSignature = { + additions: 12, + changedPaths: ['src/auth.ts', 'src/session.ts'], + changeTokenSketch: ['token', 'session', 'refresh'], + commitSha: 'unit', + deletions: 4, + exactPatchId: 'unit-patch', + filesChanged: 2, + subjectKey: 'preserve empty fields', + } satisfies CommitPatchSignature; + const overlapping = scoreBaseCommitAsRebaseDriver({ + baseSignature: { + additions: 20, + changedPaths: ['src/auth.ts', 'src/login.ts'], + changeTokenSketch: ['token', 'login'], + deletions: 3, + }, + unitSignature, + }); + const unrelated = scoreBaseCommitAsRebaseDriver({ + baseSignature: { + additions: 8, + changedPaths: ['docs/readme.md'], + changeTokenSketch: ['docs', 'readme'], + deletions: 1, + }, + unitSignature, + }); + expect(overlapping.overlappingPaths).toEqual(['src/auth.ts']); + expect(overlapping.score).toBeGreaterThan(unrelated.score); + expect(overlapping.score).toBeGreaterThanOrEqual(0.22); + }); + + test('returns ranked overlapping base commits only', () => { + const unitSignature = { + additions: 10, + changedPaths: ['src/auth.ts'], + changeTokenSketch: ['token'], + commitSha: 'unit', + deletions: 2, + exactPatchId: 'unit-patch', + filesChanged: 1, + subjectKey: 'preserve empty fields', + } satisfies CommitPatchSignature; + const baseSignatures = new Map([ + [ + 'base-strong', + { + additions: 15, + changedPaths: ['src/auth.ts', 'src/token.ts'], + changeTokenSketch: ['token', 'auth'], + commitSha: 'base-strong', + deletions: 2, + exactPatchId: 'base-strong', + filesChanged: 2, + subjectKey: 'harden auth tokens', + }, + ], + [ + 'base-weak', + { + additions: 4, + changedPaths: ['src/auth.ts'], + changeTokenSketch: ['format'], + commitSha: 'base-weak', + deletions: 1, + exactPatchId: 'base-weak', + filesChanged: 1, + subjectKey: 'format auth file', + }, + ], + [ + 'base-unrelated', + { + additions: 30, + changedPaths: ['package.json'], + changeTokenSketch: ['deps'], + commitSha: 'base-unrelated', + deletions: 5, + exactPatchId: 'base-unrelated', + filesChanged: 1, + subjectKey: 'bump deps', + }, + ], + ]); + const drivers = attributeRebaseDrivers({ + baseCommits: [ + { + authoredAt: '2026-07-01T00:00:00.000Z', + authorName: 'Ada', + sha: 'base-strong', + shortSha: 'strong', + subject: 'Harden auth tokens', + webUrl: 'https://gitlab.example/base-strong', + }, + { + authoredAt: '2026-07-02T00:00:00.000Z', + authorName: 'Grace', + sha: 'base-weak', + shortSha: 'weak', + subject: 'Format auth file', + webUrl: 'https://gitlab.example/base-weak', + }, + { + authoredAt: '2026-07-03T00:00:00.000Z', + authorName: 'Lin', + sha: 'base-unrelated', + shortSha: 'unrel', + subject: 'Bump deps', + webUrl: 'https://gitlab.example/base-unrelated', + }, + ], + baseSignatures, + unitSignature, + }); + expect(drivers.map((driver) => driver.sha)).toEqual(['base-strong', 'base-weak']); + expect(drivers[0]?.overlappingPaths).toEqual(['src/auth.ts']); + }); +}); diff --git a/gitlab/__tests__/version-compare.test.ts b/gitlab/__tests__/version-compare.test.ts new file mode 100644 index 00000000..f429937d --- /dev/null +++ b/gitlab/__tests__/version-compare.test.ts @@ -0,0 +1,602 @@ +import { expect, test } from 'vite-plus/test'; +import { + conflictResolutionFiles, + pureRebaseFiles, + pureRebaseVersions, + rebasePlusEditFiles, +} from '../fixtures/version-compare-cases.ts'; +import { + applyUnifiedPatchBody, + computeApproximatePatchTextVersionCompare, + computeLineDiff, + computeVersionComparePreferringReplay, + computeRebaseReplayVersionCompare, + materializeRebaseReplayTrees, + type MergeRequestVersionRef, + type VersionPatchFile, +} from '../src/version-compare.ts'; + +const version = (id: string, headSha: string, baseSha: string): MergeRequestVersionRef => ({ + baseSha, + createdAt: '2026-07-01T00:00:00.000Z', + headSha, + id, + label: `v${id}`, + startSha: baseSha, +}); + +const patchFile = ( + path: string, + body: string, + status: VersionPatchFile['status'] = 'modified', +): VersionPatchFile => ({ + newPath: path, + oldPath: path, + patchBody: body, + status, +}); + +test('approximate version comparison is empty for pure rebase (identical change regions)', () => { + const body = '@@ -1 +1 @@\n-old\n+new\n'; + const result = computeApproximatePatchTextVersionCompare({ + from: version('1', 'head-a', 'base-a'), + fromFiles: [patchFile('src/app.ts', body)], + to: version('2', 'head-b', 'base-b'), + toFiles: [patchFile('src/app.ts', body)], + }); + expect(result.summary.empty).toBe(true); + expect(result.summary.intentionalFiles).toBe(0); + expect(result.summary.baseMoved).toBe(true); + expect(result.files).toEqual([]); +}); + +test('approximate version comparison surfaces intentional edits after rebase', () => { + const result = computeApproximatePatchTextVersionCompare({ + comments: [ + { + commentId: '42', + filePath: 'src/app.ts', + lineNumber: 2, + position: { + baseSha: 'base-a', + headSha: 'head-a', + startSha: 'base-a', + }, + }, + ], + from: version('1', 'head-a', 'base-a'), + fromFiles: [patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new\n')], + to: version('2', 'head-b', 'base-b'), + toFiles: [patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new and safer\n')], + }); + expect(result.summary.empty).toBe(false); + expect(result.summary.addedLines).toBe(1); + expect(result.summary.deletedLines).toBe(1); + expect(result.summary.intentionalFiles).toBe(1); + expect(result.files[0]?.path).toBe('src/app.ts'); + expect(result.commentAssociations[0]?.status).toBe('resolved-by-change'); +}); + +test('does not call a comment addressed when only another hunk in the file changed', () => { + const comments = [ + { + commentId: 'comment-100', + filePath: 'src/app.ts', + lineNumber: 100, + position: { baseSha: 'base-a', headSha: 'head-a', startSha: 'base-a' }, + }, + ]; + const result = computeApproximatePatchTextVersionCompare({ + comments, + from: version('1', 'head-a', 'base-a'), + fromFiles: [ + patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new\n@@ -100 +100 @@\n-keep\n+kept\n'), + ], + to: version('2', 'head-b', 'base-a'), + toFiles: [ + patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+newer\n@@ -100 +100 @@\n-keep\n+kept\n'), + ], + }); + expect(result.commentAssociations[0]?.status).toBe('outdated'); +}); + +test('rebase-replay version comparison diffs left vs right trees', () => { + const from = version('1', 'head-a', 'base-shared'); + const to = version('2', 'head-b', 'base-shared'); + const result = computeRebaseReplayVersionCompare({ + from, + fromTree: new Map([ + ['src/app.ts', 'const value = 1;\n'], + ['README.md', 'hello\n'], + ]), + to, + toTree: new Map([ + ['src/app.ts', 'const value = 2;\n'], + ['README.md', 'hello\n'], + ]), + }); + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.filesChanged).toBe(1); + expect(result.files[0]?.path).toBe('src/app.ts'); + expect(result.summary.baseMoved).toBe(false); +}); + +test('conflict markers are classified as conflict-resolution', () => { + const result = computeApproximatePatchTextVersionCompare({ + from: version('1', 'head-a', 'base-a'), + fromFiles: [patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new\n')], + to: version('2', 'head-b', 'base-b'), + toFiles: [ + patchFile('src/app.ts', '@@ -1,3 +1,5 @@\n<<<<<<<\n-old\n=======\n+resolved\n>>>>>>>\n'), + ], + }); + expect(result.files[0]?.classes).toContain('conflict-resolution'); + expect(result.summary.conflictFiles).toBe(1); +}); + +test('fixture: pure rebase yields empty intentional set', () => { + const result = computeApproximatePatchTextVersionCompare({ + from: pureRebaseVersions.from, + fromFiles: pureRebaseFiles.from, + to: pureRebaseVersions.to, + toFiles: pureRebaseFiles.to, + }); + expect(result.summary.empty).toBe(true); + expect(result.summary.intentionalFiles).toBe(0); +}); + +test('fixture: rebase plus edit surfaces only changed files', () => { + const result = computeApproximatePatchTextVersionCompare({ + from: pureRebaseVersions.from, + fromFiles: rebasePlusEditFiles.from, + to: pureRebaseVersions.to, + toFiles: rebasePlusEditFiles.to, + }); + expect(result.summary.empty).toBe(false); + expect(result.files.map((file) => file.path).toSorted()).toEqual(['README.md', 'src/app.ts']); +}); + +test('fixture: conflict resolution is high signal', () => { + const result = computeApproximatePatchTextVersionCompare({ + from: pureRebaseVersions.from, + fromFiles: conflictResolutionFiles.from, + to: pureRebaseVersions.to, + toFiles: conflictResolutionFiles.to, + }); + expect(result.summary.conflictFiles).toBe(1); + expect(result.files[0]?.classes).toContain('conflict-resolution'); +}); + +test('applyUnifiedPatchBody applies a simple hunk', () => { + const result = applyUnifiedPatchBody( + 'const value = 1;\nold();\n', + '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n', + ); + expect(result.conflict).toBe(false); + expect(result.text).toBe('const value = 1;\nnewCall();\n'); +}); + +test('applyUnifiedPatchBody reports conflicts on context mismatch', () => { + const result = applyUnifiedPatchBody( + 'const value = 1;\nother();\n', + '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n', + ); + expect(result.conflict).toBe(true); + expect(result.text).toContain('<<<<<<<'); +}); + +test('preferring replay uses jj algorithm when blobs are available', async () => { + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + const blobs = new Map([ + ['base-b:src/app.ts', 'const value = 1;\nold();\n'], + ['head-b:src/app.ts', 'const value = 1;\nnewCall();\nguard();\n'], + ]); + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles: [ + patchFile('src/app.ts', '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n'), + ], + readBlob: (path, ref) => blobs.get(`${ref}:${path}`) ?? null, + to, + toFiles: [ + patchFile( + 'src/app.ts', + '@@ -1,2 +1,3 @@\n const value = 1;\n-old();\n+newCall();\n+guard();\n', + ), + ], + }); + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.empty).toBe(false); + expect(result.files[0]?.path).toBe('src/app.ts'); + // left = apply(from-patch onto base-b) => newCall only; right has guard + expect(result.files[0]?.file.sections[0]?.patch).toContain('+guard();'); +}); + +test('preferring replay falls back to approximate when patch reconstruction conflicts', async () => { + const result = await computeVersionComparePreferringReplay({ + from: version('1', 'head-a', 'base-a'), + fromFiles: [patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new\n')], + readBlob: async () => null, + to: version('2', 'head-b', 'base-b'), + toFiles: [patchFile('src/app.ts', '@@ -1 +1 @@\n-old\n+new and safer\n')], + }); + // Without blobs, patch reconstruction onto empty source conflicts. + // The path is reported incomplete and falls back to approximate comparison. + expect(result.algorithm).toBe('approximate-patch-text'); + expect(result.summary.empty).toBe(false); +}); + +test('preferring replay falls back when materialization cannot recover paths', async () => { + const result = await computeVersionComparePreferringReplay({ + from: version('1', 'head-a', 'base-a'), + fromFiles: [], + readBlob: async () => null, + to: version('2', 'head-b', 'base-b'), + // Binary / empty patch with no blobs and no reconstructable body. + toFiles: [ + { + newPath: 'src/binary.bin', + oldPath: 'src/binary.bin', + patchBody: '', + status: 'modified', + }, + ], + }); + expect(result.algorithm).toBe('approximate-patch-text'); + expect(result.warnings?.some((warning) => warning.includes('Fell back'))).toBe(true); +}); + +test('materialize trees rebases from-patch onto new base', async () => { + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + const trees = await materializeRebaseReplayTrees({ + from, + fromFiles: [ + patchFile('src/app.ts', '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n'), + ], + readBlob: (path, ref) => { + if (ref === 'base-b' && path === 'src/app.ts') { + return 'const value = 1;\nold();\n'; + } + if (ref === 'head-b' && path === 'src/app.ts') { + return 'const value = 1;\nnewCall();\n'; + } + return null; + }, + to, + toFiles: [ + patchFile('src/app.ts', '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n'), + ], + }); + expect(trees.fromTree.get('src/app.ts')).toBe('const value = 1;\nnewCall();\n'); + expect(trees.toTree.get('src/app.ts')).toBe('const value = 1;\nnewCall();\n'); + expect(trees.incompletePaths).toEqual([]); +}); + +test('pure rebase with blobs yields empty versionCompare', async () => { + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + const patch = '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n'; + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles: [patchFile('src/app.ts', patch)], + readBlob: (path, ref) => { + if (path !== 'src/app.ts') { + return null; + } + if (ref === 'base-b') { + return 'const value = 1;\nold();\n'; + } + if (ref === 'head-b') { + return 'const value = 1;\nnewCall();\n'; + } + return null; + }, + to, + toFiles: [patchFile('src/app.ts', patch)], + }); + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.empty).toBe(true); + expect(result.files).toEqual([]); +}); + +// === Regression tests (plan.md §4) === + +test('computeLineDiff: unchanged function in a changed file is excluded (Test A)', () => { + // Two file versions where one hunk differs and an unchanged region + // (apply_target_strategy) is identical. The localized diff must exclude + // the unchanged region entirely. + const left = + [ + 'fn setup() {', + ' init();', + '}', + '', + 'fn apply_target_strategy() {', + ' // complex logic', + ' let x = compute();', + ' validate(x);', + '}', + '', + 'fn teardown() {', + ' cleanup();', + '}', + ].join('\n') + '\n'; + + const right = + [ + 'fn setup() {', + ' init();', + ' extra_setup();', + '}', + '', + 'fn apply_target_strategy() {', + ' // complex logic', + ' let x = compute();', + ' validate(x);', + '}', + '', + 'fn teardown() {', + ' cleanup();', + '}', + ].join('\n') + '\n'; + + const result = computeLineDiff(left, right); + expect(result.incomplete).toBe(false); + expect(result.patchBody).not.toBe(''); + // The diff should contain the added line. + expect(result.patchBody).toContain('+ extra_setup();'); + // The unchanged function must NOT appear as a changed line. + expect(result.patchBody).not.toContain('+fn apply_target_strategy'); + expect(result.patchBody).not.toContain('-fn apply_target_strategy'); + expect(result.patchBody).not.toContain('+ let x = compute'); + expect(result.patchBody).not.toContain('- let x = compute'); + expect(result.patchBody).not.toContain('+ validate(x)'); + expect(result.patchBody).not.toContain('- validate(x)'); +}); + +test('rebase-replay produces localized hunks for modified files (Test A end-to-end)', () => { + const from = version('1', 'head-a', 'base-shared'); + const to = version('2', 'head-b', 'base-shared'); + const unchanged = [ + '', + 'fn apply_target_strategy() {', + ' // complex logic', + ' let x = compute();', + ' validate(x);', + '}', + ].join('\n'); + + const result = computeRebaseReplayVersionCompare({ + from, + fromTree: new Map([['authority.rs', `fn setup() {\n init();\n}${unchanged}\n`]]), + to, + toTree: new Map([ + ['authority.rs', `fn setup() {\n init();\n extra_setup();\n}${unchanged}\n`], + ]), + }); + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.filesChanged).toBe(1); + const patch = result.files[0]?.file.sections[0]?.patch ?? ''; + expect(patch).toContain('+ extra_setup();'); + // apply_target_strategy must not appear as a change. + expect(patch).not.toContain('-fn apply_target_strategy'); + expect(patch).not.toContain('+fn apply_target_strategy'); + expect(patch).not.toContain('- let x = compute'); + expect(patch).not.toContain('+ let x = compute'); +}); + +test('large path count uses rebase-replay, not approximate fallback (Test B)', async () => { + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + + // Create 20 files — well above the old 12-path cutoff. + const fromFiles: Array> = []; + const toFiles: Array> = []; + const blobs = new Map(); + for (let i = 0; i < 20; i++) { + const path = `src/file-${i}.ts`; + fromFiles.push(patchFile(path, `@@ -1 +1 @@\n-old${i}\n+new${i}\n`)); + toFiles.push(patchFile(path, `@@ -1 +1 @@\n-old${i}\n+new${i} revised\n`)); + blobs.set(`base-b:${path}`, `old${i}\n`); + blobs.set(`head-b:${path}`, `new${i} revised\n`); + } + + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles, + readBlob: (path, ref) => blobs.get(`${ref}:${path}`) ?? null, + to, + toFiles, + }); + + // Must use rebase-replay, NOT approximate-patch-text. + expect(result.algorithm).toBe('jj-rebase-replay'); + // Should contain localized hunks (not whole-file replacements). + expect(result.summary.filesChanged).toBe(20); + // No warning about "patch-text version comparison because this range contains N files". + expect( + result.warnings?.some((w) => w.includes('patch-text version comparison because')), + ).toBeFalsy(); +}); + +test('likely-revised interdiff produces localized hunks (Test C)', async () => { + // A likely-revised commit pair where one function changed and another did not. + const from = version('old-sha', 'old-sha', 'old-parent'); + const to = version('new-sha', 'new-sha', 'new-parent'); + + const unchangedFn = '\nfn untouched() {\n stable_code();\n}\n'; + const fromPatch = `@@ -1,2 +1,2 @@\n fn main() {\n- v1();\n+ v1_impl();\n`; + const toPatch = `@@ -1,2 +1,2 @@\n fn main() {\n- v1();\n+ v2_impl();\n`; + + const blobs = new Map(); + blobs.set('new-parent:src/lib.rs', `fn main() {\n v1();\n}${unchangedFn}`); + blobs.set('new-sha:src/lib.rs', `fn main() {\n v2_impl();\n}${unchangedFn}`); + + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles: [patchFile('src/lib.rs', fromPatch)], + readBlob: (path, ref) => blobs.get(`${ref}:${path}`) ?? null, + to, + toFiles: [patchFile('src/lib.rs', toPatch)], + }); + + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.filesChanged).toBe(1); + const patch = result.files[0]?.file.sections[0]?.patch ?? ''; + // Should show the actual change. + expect(patch).toContain('- v1_impl();'); + expect(patch).toContain('+ v2_impl();'); + // The unchanged function must NOT appear as a change (+ or - prefix). + // It may appear as context (space prefix) — that is acceptable per plan.md. + expect(patch).not.toContain('-fn untouched'); + expect(patch).not.toContain('+fn untouched'); + expect(patch).not.toContain('- stable_code'); + expect(patch).not.toContain('+ stable_code'); +}); + +test('computeLineDiff handles identical files', () => { + const result = computeLineDiff('hello\nworld\n', 'hello\nworld\n'); + expect(result.incomplete).toBe(false); + expect(result.patchBody).toBe(''); +}); + +test('computeLineDiff handles empty-to-content', () => { + const result = computeLineDiff('', 'new line\n'); + expect(result.incomplete).toBe(false); + expect(result.patchBody).toContain('+new line'); +}); + +test('computeLineDiff handles content-to-empty', () => { + const result = computeLineDiff('old line\n', ''); + expect(result.incomplete).toBe(false); + expect(result.patchBody).toContain('-old line'); +}); + +test('computeLineDiff produces multiple independent hunks', () => { + // Two changes separated by many unchanged lines. + const shared = Array.from({ length: 20 }, (_, i) => `line ${i + 2}`).join('\n'); + const left = `first\n${shared}\nlast\n`; + const right = `FIRST\n${shared}\nLAST\n`; + const result = computeLineDiff(left, right); + expect(result.incomplete).toBe(false); + // Should produce two separate hunks. + const hunkHeaders = result.patchBody.match(/@@ /g); + expect(hunkHeaders?.length).toBe(2); +}); + +test('rebase-replay: added file shows full addition patch (Test D)', () => { + const from = version('1', 'head-a', 'base-shared'); + const to = version('2', 'head-b', 'base-shared'); + const result = computeRebaseReplayVersionCompare({ + from, + fromTree: new Map(), + to, + toTree: new Map([['new-file.ts', 'export const x = 1;\n']]), + }); + expect(result.summary.filesChanged).toBe(1); + expect(result.files[0]?.status).toBe('added'); + const patch = result.files[0]?.file.sections[0]?.patch ?? ''; + expect(patch).toContain('+export const x = 1;'); +}); + +test('rebase-replay: removed file shows full deletion patch (Test D)', () => { + const from = version('1', 'head-a', 'base-shared'); + const to = version('2', 'head-b', 'base-shared'); + const result = computeRebaseReplayVersionCompare({ + from, + fromTree: new Map([['old-file.ts', 'export const y = 2;\n']]), + to, + toTree: new Map(), + }); + expect(result.summary.filesChanged).toBe(1); + expect(result.files[0]?.status).toBe('deleted'); + const patch = result.files[0]?.file.sections[0]?.patch ?? ''; + expect(patch).toContain('-export const y = 2;'); +}); + +test('rebase-replay reports incompleteDiffPaths for pathological diffs', () => { + const from = version('1', 'head-a', 'base-shared'); + const to = version('2', 'head-b', 'base-shared'); + // Create two completely different files that exceed the Myers cap. + const left = Array.from({ length: 1500 }, (_, i) => `unique-old-${i}`).join('\n') + '\n'; + const right = Array.from({ length: 1500 }, (_, i) => `unique-new-${i}`).join('\n') + '\n'; + const result = computeRebaseReplayVersionCompare({ + from, + fromTree: new Map([['big.ts', left]]), + to, + toTree: new Map([['big.ts', right]]), + }); + // The pathological diff should be reported as incomplete, not as a whole-file replacement. + expect(result.incompleteDiffPaths).toContain('big.ts'); + expect(result.files.find((f) => f.path === 'big.ts')).toBeUndefined(); +}); + +test('conflict during patch replay falls back to direct head blob comparison', async () => { + // When replaying v11's patch onto v20's base produces a conflict (because + // the base changed), we should fall back to comparing the actual v11 head + // blob against the v20 head blob. This produces a real diff that may include + // some base-change noise but never shows raw +/- patch syntax. + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + + // The from-patch expects "old line" at line 1, but the new base has "different base line". + const fromPatch = '@@ -1,2 +1,2 @@\n old line\n-remove me\n+add me\n'; + const toPatch = '@@ -1,2 +1,2 @@\n different base line\n-other\n+result\n'; + + const blobs = new Map(); + blobs.set('base-b:src/app.rs', 'different base line\nother\n'); + blobs.set('head-a:src/app.rs', 'old line\nadd me\n'); + blobs.set('head-b:src/app.rs', 'different base line\nresult\n'); + + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles: [patchFile('src/app.rs', fromPatch)], + readBlob: (path, ref) => blobs.get(`${ref}:${path}`) ?? null, + to, + toFiles: [patchFile('src/app.rs', toPatch)], + }); + + // Should still use rebase-replay algorithm (with blob fallback), not approximate. + expect(result.algorithm).toBe('jj-rebase-replay'); + expect(result.summary.filesChanged).toBe(1); + const patch = result.files[0]?.file.sections[0]?.patch ?? ''; + // Must never contain conflict markers. + expect(patch).not.toContain('<<<<<<< source'); + expect(patch).not.toContain('>>>>>>> patch'); + // Should show the actual diff between head-a and head-b content. + expect(patch).toContain('-old line'); + expect(patch).toContain('+different base line'); + // Warning should mention the replay conflict fallback. + expect(result.warnings?.some((w) => w.includes('Replay conflict'))).toBe(true); +}); + +test('conflict during replay with no head blob falls back to approximate', async () => { + const from = version('1', 'head-a', 'base-a'); + const to = version('2', 'head-b', 'base-b'); + + const fromPatch = '@@ -1,2 +1,2 @@\n old line\n-remove me\n+add me\n'; + const toPatch = '@@ -1,2 +1,2 @@\n different base line\n-other\n+result\n'; + + const blobs = new Map(); + blobs.set('base-b:src/app.rs', 'different base line\nother\n'); + blobs.set('head-b:src/app.rs', 'different base line\nresult\n'); + // head-a blob NOT available — can't do direct comparison + + const result = await computeVersionComparePreferringReplay({ + from, + fromFiles: [patchFile('src/app.rs', fromPatch)], + readBlob: (path, ref) => blobs.get(`${ref}:${path}`) ?? null, + to, + toFiles: [patchFile('src/app.rs', toPatch)], + }); + + // Without head-a blob, path is incomplete → approximate fallback. + // Must still never contain conflict markers. + for (const file of result.files) { + const patch = file.file.sections[0]?.patch ?? ''; + expect(patch).not.toContain('<<<<<<< source'); + expect(patch).not.toContain('>>>>>>> patch'); + } +}); diff --git a/gitlab/fixtures/version-compare-cases.ts b/gitlab/fixtures/version-compare-cases.ts new file mode 100644 index 00000000..c10b9971 --- /dev/null +++ b/gitlab/fixtures/version-compare-cases.ts @@ -0,0 +1,83 @@ +import type { MergeRequestVersionRef, VersionPatchFile } from '../version-compare.ts'; + +/** Synthetic fixtures used for version-comparison algorithm coverage without live GitLab. */ + +export const pureRebaseVersions = { + from: { + baseSha: 'base-a', + createdAt: '2026-06-01T00:00:00.000Z', + headSha: 'head-a', + id: '1', + label: 'v1 · head-a', + startSha: 'base-a', + }, + to: { + baseSha: 'base-b', + createdAt: '2026-06-02T00:00:00.000Z', + headSha: 'head-b', + id: '2', + label: 'v2 · head-b', + startSha: 'base-b', + }, +} satisfies { from: MergeRequestVersionRef; to: MergeRequestVersionRef }; + +const sameLogicalPatch = '@@ -1,2 +1,2 @@\n const value = 1;\n-old();\n+newCall();\n'; + +export const pureRebaseFiles = { + from: [ + { + newPath: 'src/app.ts', + oldPath: 'src/app.ts', + patchBody: sameLogicalPatch, + status: 'modified', + }, + ], + to: [ + { + newPath: 'src/app.ts', + oldPath: 'src/app.ts', + // Context lines shifted after rebase, same change regions. + patchBody: '@@ -10,2 +10,2 @@\n const value = 1;\n-old();\n+newCall();\n', + status: 'modified', + }, + ], +} satisfies { + from: ReadonlyArray; + to: ReadonlyArray; +}; + +export const rebasePlusEditFiles = { + from: pureRebaseFiles.from, + to: [ + { + newPath: 'src/app.ts', + oldPath: 'src/app.ts', + patchBody: '@@ -10,2 +10,3 @@\n const value = 1;\n-old();\n+newCall();\n+guard();\n', + status: 'modified', + }, + { + newPath: 'README.md', + oldPath: 'README.md', + patchBody: '@@ -1 +1 @@\n-hello\n+hello world\n', + status: 'modified', + }, + ], +} satisfies { + from: ReadonlyArray; + to: ReadonlyArray; +}; + +export const conflictResolutionFiles = { + from: pureRebaseFiles.from, + to: [ + { + newPath: 'src/app.ts', + oldPath: 'src/app.ts', + patchBody: '@@ -1,5 +1,7 @@\n<<<<<<< HEAD\n-old();\n=======\n+resolved();\n>>>>>>> feature\n', + status: 'modified', + }, + ], +} satisfies { + from: ReadonlyArray; + to: ReadonlyArray; +}; From 0f7f467116042d5b09f3d14cf46ea83ae28445fd Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:50 -0500 Subject: [PATCH 08/20] Document Codiff Web review-history package boundaries Describe the shared Core and GitLab APIs, walkthrough-authoring ownership, generation inputs, and the Codiff Web migration checklist. Keep local Electron authentication and IPC guidance out of this document so the foundation MR remains scoped to reusable Web-facing packages. --- docs/review-history-packages.md | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/review-history-packages.md diff --git a/docs/review-history-packages.md b/docs/review-history-packages.md new file mode 100644 index 00000000..73a9ed7f --- /dev/null +++ b/docs/review-history-packages.md @@ -0,0 +1,96 @@ +# Review-history package notes + +These packages are the Codiff-side extraction target for Codiff Web. + +## Packages + +| Package | Notes | +| --------------------- | -------------------------------------------------------------------------------- | +| `@nkzw/codiff-core` | Review-history contracts + `walkthrough-authoring` + `generateReviewWalkthrough` | +| `@nkzw/codiff-gitlab` | Read-side GitLab history adapter over `GitLabTransport` | + +## New Core contracts + +Import from `@nkzw/codiff-core` / `@nkzw/codiff-core/types`: + +- `RevisionLabel`, `RevisionRef`, `DiffRange`, `DiffComparison` +- `DiffComparisonAnalysis`, `DiffComparisonView` +- `ReviewUnit`, `ReviewEvolutionUnit`, `ReviewCommitEvolution` +- `ReviewStructureRecommendation`, `ReviewPlan` +- `WalkthroughGenerationInput` +- helpers: `resolveReviewPlan`, `reviewVersionOption`, `project`-style builders in `lib/review-history` + +Shared review UI still exports compatibility aliases such as +`MergeRequestVersionOption`, but those now alias the Core contracts above. + +## Walkthrough authoring + +Import from `@nkzw/codiff-core/walkthrough-authoring`: + +- `parseWalkthroughDraft` / `parseAuthoredWalkthrough` +- `parseRepositoryState` +- `indexWalkthroughHunks` +- `buildWalkthroughPrompt` / `buildWalkthroughPromptInput` +- `normalizeWalkthroughDraft` / `normalizeAuthoredWalkthrough` +- `attachVersionCommentReferences` +- `composeUnitWalkthroughs` / `combineCommitWalkthroughs` + +This module owns draft validation, hunk aliasing, prompt construction, +normalization, and unit composition. Hosts keep model IDs, AI Gateway routing, +retries, Durable Object orchestration, and cache policy. + +Generation input rule: + +- whole-diff plan -> one `RepositoryState` +- units plan -> one `RepositoryState` per `ReviewUnit` + +## GitLab package + +Import from `@nkzw/codiff-gitlab`. + +Host implements: + +```ts +type GitLabTransport = { + request(request: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + query?: Readonly>; + body?: unknown; + }): Promise; + requestPages?(request: { + path: string; + query?: Readonly>; + }): Promise>; + requestText?(request: { + path: string; + query?: Readonly>; + }): Promise; +}; +``` + +Package owns: + +- endpoint construction and pagination +- version list / historical commit+diff loading +- version comparison materialization +- patch signatures, commit evolution, review-structure classification +- projection helpers: `projectVersionCompare`, `projectCommitEvolution`, + `projectReviewPlan`, `toGitLabDiffIdentity` + +Not migrated in this package extraction: + +- baseline current-MR snapshot loading +- write-side comments/reviews/merge mutations +- host authentication + +## Codiff Web migration checklist + +1. Depend on `@nkzw/codiff-core` and `@nkzw/codiff-gitlab`. +2. Implement Worker `GitLabTransport` over `GITLAB_VPC_BRIDGE`. +3. Replace local version-compare / evolution / review-strategy copies with package APIs. +4. Replace local walkthrough draft/prompt/normalize/combine helpers with + `@nkzw/codiff-core/walkthrough-authoring`. +5. Keep D1/R2, Durable Objects, Think/AI Gateway, and Fate orchestration in Web. +6. Feed Core `MergeRequestReviewApp` with projected Core contracts, not GitLab + wire shapes. From 0064afc8314f2f1fd957d2e1e37682cd0dd2ef8a Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 17:10:51 -0500 Subject: [PATCH 09/20] Add shared walkthrough generation orchestration Resolve review plans into whole-diff or per-unit generation, invoke a host-supplied model runner, normalize each authored result, and compose unit walkthroughs in Core. This lets Codiff Web share one orchestration contract while retaining ownership of model selection, queues, retries, and persistence. --- .../generate-review-walkthrough.test.ts | 153 +++++++++ core/index.ts | 7 + core/lib/generate-review-walkthrough.ts | 301 ++++++++++++++++++ core/walkthrough-authoring.ts | 8 + 4 files changed, 469 insertions(+) create mode 100644 core/__tests__/generate-review-walkthrough.test.ts create mode 100644 core/lib/generate-review-walkthrough.ts diff --git a/core/__tests__/generate-review-walkthrough.test.ts b/core/__tests__/generate-review-walkthrough.test.ts new file mode 100644 index 00000000..c38098cd --- /dev/null +++ b/core/__tests__/generate-review-walkthrough.test.ts @@ -0,0 +1,153 @@ +import { expect, test, vi } from 'vite-plus/test'; +import { generateReviewWalkthrough } from '../lib/generate-review-walkthrough.ts'; +import type { RepositoryState, ReviewCommitEvolution } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; + +const baseState = { + branch: 'feature', + files: [createChangedFile('src/app.ts')], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: { + number: 1, + provider: 'github', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/1', + }, +} satisfies RepositoryState; + +const draft = { + chapters: [ + { + blurb: 'Review the change.', + icon: 'gear', + id: 'c1', + stops: [ + { + hunkIds: ['h1'], + id: 's1', + importance: 'critical', + prose: 'Check this.', + title: 'Main change', + }, + ], + title: 'Core', + }, + ], + focus: 'Focus', + kind: 'narrative', + title: 'Walkthrough', + version: 4, +}; + +test('generateReviewWalkthrough whole-diff path prompts once and normalizes', async () => { + const runModel = vi.fn(async () => ({ draft })); + const result = await generateReviewWalkthrough({ + agent: 'codex', + runModel, + states: { whole: baseState }, + structure: 'whole-diff', + }); + expect(result.status).toBe('ready'); + if (result.status !== 'ready') { + return; + } + expect(runModel).toHaveBeenCalledTimes(1); + expect(result.walkthrough.kind).toBe('narrative'); + expect(result.plan.structure).toBe('whole-diff'); +}); + +test('generateReviewWalkthrough units path fans out and composes', async () => { + const evolution = { + recommendation: { + rationale: 'Multiple units changed.', + suggestedStructure: 'commit-by-commit', + }, + summary: { + absorbedIntoBase: 0, + added: 2, + ambiguous: 0, + pairingCoverage: 0, + removed: 0, + retained: 0, + reviewable: 2, + revised: 0, + rewrittenSamePatch: 0, + }, + units: [ + { + after: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: 'a'.repeat(40), + shortSha: 'aaaaaaa', + subject: 'one', + }, + confidence: 'exact', + id: 'introduced:a', + kind: 'introduced', + order: 0, + reviewable: true, + }, + { + after: { + authoredAt: '2026-01-01T01:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: 'b'.repeat(40), + shortSha: 'bbbbbbb', + subject: 'two', + }, + confidence: 'exact', + id: 'introduced:b', + kind: 'introduced', + order: 1, + reviewable: true, + }, + ], + } satisfies ReviewCommitEvolution; + + const runModel = vi.fn(async () => ({ draft })); + const unitState = { + ...baseState, + files: [createChangedFile('src/unit.ts')], + } satisfies RepositoryState; + + const result = await generateReviewWalkthrough({ + agent: 'codex', + evolution, + runModel, + states: { + byUnitId: { + 'introduced:a': unitState, + 'introduced:b': unitState, + }, + whole: baseState, + }, + structure: 'units', + }); + + expect(result.status).toBe('ready'); + if (result.status !== 'ready') { + return; + } + expect(runModel).toHaveBeenCalledTimes(2); + expect(result.plan.structure).toBe('units'); + expect(result.walkthrough.chapters.length).toBeGreaterThan(0); + expect(result.walkthrough.title).toContain('Commit-by-commit'); +}); + +test('generateReviewWalkthrough fails clearly without whole state', async () => { + const result = await generateReviewWalkthrough({ + agent: 'codex', + runModel: async () => ({ draft }), + states: {}, + structure: 'whole-diff', + }); + expect(result).toEqual({ + reason: 'Whole-diff walkthrough generation requires a whole RepositoryState.', + status: 'failed', + }); +}); diff --git a/core/index.ts b/core/index.ts index e1f8bd16..f84f04a1 100644 --- a/core/index.ts +++ b/core/index.ts @@ -38,6 +38,13 @@ export { type VersionCommitSummary, type VersionRebaseDriverCommit, } from './lib/commit-stack-evolution.ts'; +export { + generateReviewWalkthrough, + type GenerateReviewWalkthroughInput, + type GenerateReviewWalkthroughResult, + type ReviewWalkthroughModelResult, + type ReviewWalkthroughRunModel, +} from './lib/generate-review-walkthrough.ts'; export { parsePlanShareManifest, parsePlanShareUpload, diff --git a/core/lib/generate-review-walkthrough.ts b/core/lib/generate-review-walkthrough.ts new file mode 100644 index 00000000..1cbc47d3 --- /dev/null +++ b/core/lib/generate-review-walkthrough.ts @@ -0,0 +1,301 @@ +import type { + NarrativeWalkthrough, + RepositoryState, + ReviewCommitEvolution, + ReviewPlan, + ReviewUnit, +} from '../types.ts'; +import { resolveReviewPlan, reviewableUnits } from './review-history.ts'; +import { + buildWalkthroughPrompt, + composeUnitWalkthroughs, + normalizeWalkthroughDraft, + type UnitWalkthroughEntry, + type WalkthroughPromptOptions, +} from './walkthrough-authoring.ts'; + +export type ReviewWalkthroughModelResult = { + /** Raw model JSON/text payload to normalize. */ + draft: unknown; +}; + +export type ReviewWalkthroughRunModel = (input: { + agent: NarrativeWalkthrough['agent']; + prompt: string; + state: RepositoryState; +}) => Promise; + +export type GenerateReviewWalkthroughInput = { + agent: NarrativeWalkthrough['agent']; + /** + * Optional evolution used when `plan` is omitted and unit generation is desired. + * Hosts typically pass the projected Core evolution for the active compare. + */ + evolution?: ReviewCommitEvolution | null; + /** + * Optional explicit plan. When omitted, resolved from evolution recommendation + * (units if recommended and unit states exist; otherwise whole-diff). + */ + plan?: ReviewPlan | null; + /** Extra prompt options applied to every generation call. */ + promptOptions?: WalkthroughPromptOptions; + /** Host-provided model runner (local agent CLI, Think, etc.). */ + runModel: ReviewWalkthroughRunModel; + /** + * Whole-diff state and/or per-unit states. + * - whole-diff plan: provide `whole` + * - units plan: provide `byUnitId` for each reviewable unit (missing units are skipped) + */ + states: { + byUnitId?: Readonly>; + whole?: RepositoryState; + }; + /** Force whole-diff even when a units plan is available. */ + structure?: 'auto' | 'commit-by-commit' | 'whole-diff' | 'units'; +}; + +export type GenerateReviewWalkthroughResult = + | { + plan: ReviewPlan; + status: 'ready'; + walkthrough: NarrativeWalkthrough; + } + | { + reason: string; + status: 'failed'; + }; + +const toEvolutionKind = ( + kind: ReviewUnit['kind'], +): 'likely-revised' | 'added' | 'removed' | 'ambiguous' => { + switch (kind) { + case 'introduced': + return 'added'; + case 'removed': + return 'removed'; + case 'revised': + return 'likely-revised'; + default: + return 'ambiguous'; + } +}; + +const unitAfter = (unit: ReviewUnit) => + unit.kind === 'commit' + ? unit.commit + : unit.kind === 'introduced' || unit.kind === 'revised' || unit.kind === 'ambiguous' + ? unit.after + : undefined; + +const unitBefore = (unit: ReviewUnit) => + unit.kind === 'removed' || unit.kind === 'revised' || unit.kind === 'ambiguous' + ? unit.before + : undefined; + +const toPromptOptionsForUnit = ( + unit: ReviewUnit, + range: { fromLabel: string; toLabel: string }, + base: WalkthroughPromptOptions | undefined, +): WalkthroughPromptOptions => { + const after = unitAfter(unit); + const before = unitBefore(unit); + return { + ...base, + versionCommitContext: { + evolutionKind: toEvolutionKind(unit.kind), + kind: 'version-commit', + range, + unitId: unit.id, + ...(after ? { after: { shortSha: after.shortSha, subject: after.subject } } : {}), + ...(before ? { before: { shortSha: before.shortSha, subject: before.subject } } : {}), + ...('rebaseDrivers' in unit && unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + authorName: driver.authorName, + overlappingPaths: driver.overlappingPaths ?? [], + shortSha: driver.shortSha, + subject: driver.subject, + })), + } + : {}), + }, + versionCompareRange: { + fromLabel: range.fromLabel, + structure: 'commit-by-commit', + toLabel: range.toLabel, + }, + }; +}; + +/** + * Host-agnostic walkthrough generation orchestrator. + * + * Owns plan resolution + whole-diff vs unit fanout + composition. + * Does **not** own model IDs, process execution, caching, or auth — hosts inject + * `runModel` for that. + * + * ## Local vs Web + * - Local: `runModel` shells out to codex/claude/opencode/pi and returns parsed JSON. + * - Web (later): `runModel` calls Think/AI Gateway; Durable Object fanout can still + * pre-build per-unit states and call this helper once per unit or once for whole-diff. + * + * Prompt construction, draft normalization, and unit composition stay inside + * `walkthrough-authoring` so both hosts cannot drift. + */ +export async function generateReviewWalkthrough( + input: GenerateReviewWalkthroughInput, +): Promise { + const plan = + input.plan ?? + resolveReviewPlan({ + recommendation: input.evolution?.recommendation, + structure: input.structure === 'commit-by-commit' ? 'units' : input.structure, + units: input.evolution?.units, + }); + + if (plan.structure === 'whole-diff') { + const state = input.states.whole; + if (!state) { + return { + reason: 'Whole-diff walkthrough generation requires a whole RepositoryState.', + status: 'failed', + }; + } + if (state.files.length === 0) { + return { + reason: 'No changed files are available for walkthrough generation.', + status: 'failed', + }; + } + try { + const prompt = buildWalkthroughPrompt(state, input.promptOptions); + const { draft } = await input.runModel({ + agent: input.agent, + prompt, + state, + }); + const walkthrough = normalizeWalkthroughDraft(draft, state, input.agent); + return { plan, status: 'ready', walkthrough }; + } catch (error: unknown) { + return { + reason: error instanceof Error ? error.message : String(error), + status: 'failed', + }; + } + } + + const units = plan.units?.length + ? plan.units + : input.evolution + ? reviewableUnits(input.evolution.units) + : []; + if (units.length === 0) { + return { + reason: 'Unit walkthrough generation requires reviewable evolution units.', + status: 'failed', + }; + } + + const wholeState = input.states.whole; + if (!wholeState) { + return { + reason: 'Unit composition requires the parent whole RepositoryState.', + status: 'failed', + }; + } + + const range = { + fromLabel: input.promptOptions?.versionCompareRange?.fromLabel ?? 'before', + toLabel: input.promptOptions?.versionCompareRange?.toLabel ?? 'after', + }; + + const entries: Array = []; + const failures: Array = []; + + for (const unit of units) { + const unitState = input.states.byUnitId?.[unit.id]; + if (!unitState || unitState.files.length === 0) { + failures.push(`${unit.id}: missing unit diff state`); + continue; + } + try { + const prompt = buildWalkthroughPrompt( + unitState, + toPromptOptionsForUnit(unit, range, input.promptOptions), + ); + const { draft } = await input.runModel({ + agent: input.agent, + prompt, + state: unitState, + }); + const walkthrough = normalizeWalkthroughDraft(draft, unitState, input.agent); + const after = unitAfter(unit); + const before = unitBefore(unit); + const context: UnitWalkthroughEntry['context'] = { + kind: 'version-commit', + range, + unitId: unit.id, + ...(after + ? { + after: { + sha: after.sha, + shortSha: after.shortSha, + subject: after.subject, + ...(after.webUrl ? { webUrl: after.webUrl } : {}), + }, + } + : {}), + ...(before + ? { + before: { + sha: before.sha, + shortSha: before.shortSha, + subject: before.subject, + ...(before.webUrl ? { webUrl: before.webUrl } : {}), + }, + } + : {}), + ...('rebaseDrivers' in unit && unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + ...(driver.authoredAt ? { authoredAt: driver.authoredAt } : {}), + ...(driver.authorName ? { authorName: driver.authorName } : {}), + ...(driver.overlappingPaths + ? { overlappingPaths: [...driver.overlappingPaths] } + : {}), + ...(driver.sha ? { sha: driver.sha } : {}), + shortSha: driver.shortSha, + subject: driver.subject, + ...(driver.webUrl ? { webUrl: driver.webUrl } : {}), + })), + } + : {}), + }; + entries.push({ + context, + state: unitState, + walkthrough, + }); + } catch (error: unknown) { + failures.push(`${unit.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (entries.length === 0) { + return { + reason: + failures.length > 0 + ? `All unit walkthroughs failed. ${failures.join('; ')}` + : 'No unit walkthroughs were generated.', + status: 'failed', + }; + } + + const walkthrough = composeUnitWalkthroughs({ + agent: input.agent, + entries, + state: wholeState, + }); + + return { plan, status: 'ready', walkthrough }; +} diff --git a/core/walkthrough-authoring.ts b/core/walkthrough-authoring.ts index e97c4876..b18b36cb 100644 --- a/core/walkthrough-authoring.ts +++ b/core/walkthrough-authoring.ts @@ -27,3 +27,11 @@ export { type WalkthroughPromptOptions, type WalkthroughReviewStrategy, } from './lib/walkthrough-authoring.ts'; + +export { + generateReviewWalkthrough, + type GenerateReviewWalkthroughInput, + type GenerateReviewWalkthroughResult, + type ReviewWalkthroughModelResult, + type ReviewWalkthroughRunModel, +} from './lib/generate-review-walkthrough.ts'; From 6967d11160050979c4c45fd89fcc42ededacae02 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 18:05:17 -0500 Subject: [PATCH 10/20] Centralize provider-neutral evolution projection Move commit-evolution projection and shared unit helpers into Core so provider adapters produce the same review-history model. Update the GitLab adapter to consume that shared projection instead of owning provider-specific copies, preparing the API used by Codiff Web and later GitHub integration. --- core/index.ts | 23 +---- core/lib/commit-stack-evolution.ts | 152 ++++++++++++++++++++++++++++- gitlab/src/history.ts | 135 +------------------------ 3 files changed, 153 insertions(+), 157 deletions(-) diff --git a/core/index.ts b/core/index.ts index f84f04a1..f3066731 100644 --- a/core/index.ts +++ b/core/index.ts @@ -22,6 +22,8 @@ export { attributeRebaseDrivers, createCommitPatchSignature, matchVersionCommitStacks, + projectCommitEvolution, + projectEvolutionUnit, recommendVersionWalkthroughStructure, scoreBaseCommitAsRebaseDriver, toVersionCommitSummary, @@ -105,24 +107,3 @@ export type { WalkthroughShareManifestV1, WalkthroughHunk, } from './types.ts'; -export { - formatVersionElapsedDuration, - MergeRequestReviewApp, - ReadOnlyGeneralCommentCard, - ReviewSurface, - type MergeRequestCommitListEntry, - type MergeRequestReviewAppProps, - type MergeRequestReviewMode, - type MergeRequestReviewStrategySummary, - type MergeRequestVersionBaseMovement, - type MergeRequestVersionBaseMovementCommit, - type MergeRequestVersionCommitEvolution, - type MergeRequestVersionCommitEvolutionUnit, - type MergeRequestVersionCommitSummary, - type MergeRequestVersionCompareCommentAssociation, - type MergeRequestVersionCompareSummary, - type MergeRequestVersionCompareView, - type MergeRequestVersionOption, - type MergeRequestVersionRebaseDriverCommit, - type MergeRequestWalkthroughStatus, -} from './SharedWalkthroughApp.tsx'; diff --git a/core/lib/commit-stack-evolution.ts b/core/lib/commit-stack-evolution.ts index f2376202..bb621731 100644 --- a/core/lib/commit-stack-evolution.ts +++ b/core/lib/commit-stack-evolution.ts @@ -4,7 +4,12 @@ * Used by GitLab MR versions and GitHub force-push head comparisons so both * forges share one pairing algorithm. */ -import type { ChangedFile } from '../types.ts'; +import type { + ChangedFile, + ReviewCommitEvolution, + ReviewCommitSummary, + ReviewEvolutionUnit, +} from '../types.ts'; import { sha256 } from './crypto.ts'; export const versionCommitSignatureAlgorithmVersion = 'patch-signature-v1'; @@ -79,17 +84,17 @@ export type VersionCommitEvolutionUnit = { /** Minimal endpoint identity needed for evolution range bookkeeping. */ export type DiffEndpointRef = { baseSha: string; + createdAt?: string; headSha: string; id?: string; label?: string; startSha?: string; - createdAt?: string; }; export type CommitStackEvolutionRange = { from: DiffEndpointRef; - to: DiffEndpointRef; paths?: ReadonlyArray; + to: DiffEndpointRef; }; export type CommitStackEvolution = { @@ -828,3 +833,144 @@ export const attributeRebaseDrivers = ({ .slice(0, limit) .map(({ score: _score, ...commit }) => commit); }; + +type ProjectableCommit = { + authoredAt: string; + authorName: string; + diffStat?: { additions: number; deletions: number; filesChanged: number }; + parentIds?: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; +}; + +const projectCommitSummary = ( + commit: ProjectableCommit | undefined, +): ReviewCommitSummary | undefined => { + if (!commit) { + return undefined; + } + return { + authoredAt: commit.authoredAt, + authorName: commit.authorName, + parentIds: commit.parentIds ?? [], + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + webUrl: commit.webUrl, + ...(commit.diffStat ? { diffStat: commit.diffStat } : {}), + }; +}; + +/** + * Project forge-internal evolution units onto Core {@link ReviewEvolutionUnit}. + * Maps GitLab-ish kinds (`added` / `likely-revised`) onto host UI kinds + * (`introduced` / `revised`). + */ +export const projectEvolutionUnit = (unit: { + after?: ProjectableCommit; + baseCommit?: ProjectableCommit; + before?: ProjectableCommit; + confidence: 'exact' | 'high' | 'unmatched'; + id: string; + kind: string; + matchReasons?: ReadonlyArray; + matchScore?: number; + order: number; + rebaseDrivers?: ReadonlyArray<{ + authoredAt: string; + authorName: string; + overlappingPaths: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + webUrl?: string; + }>; + reviewable: boolean; +}): ReviewEvolutionUnit => { + const common = { + confidence: unit.confidence, + id: unit.id, + order: unit.order, + ...(unit.matchReasons ? { matchReasons: unit.matchReasons } : {}), + ...(unit.matchScore != null ? { matchScore: unit.matchScore } : {}), + } as const; + if (unit.kind === 'added' || unit.kind === 'introduced') { + return { + ...common, + after: projectCommitSummary(unit.after)!, + kind: 'introduced', + reviewable: true, + }; + } + if (unit.kind === 'removed') { + return { + ...common, + before: projectCommitSummary(unit.before)!, + kind: 'removed', + reviewable: true, + }; + } + if (unit.kind === 'likely-revised' || unit.kind === 'revised') { + return { + ...common, + after: projectCommitSummary(unit.after)!, + before: projectCommitSummary(unit.before)!, + kind: 'revised', + reviewable: true, + ...(unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + authoredAt: driver.authoredAt, + authorName: driver.authorName, + overlappingPaths: driver.overlappingPaths, + sha: driver.sha, + shortSha: driver.shortSha, + subject: driver.subject, + webUrl: driver.webUrl, + })), + } + : {}), + }; + } + if (unit.kind === 'ambiguous') { + return { + ...common, + kind: 'ambiguous', + reviewable: true, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; + } + if (unit.kind === 'absorbed-into-base') { + return { + ...common, + kind: 'absorbed-into-base', + reviewable: false, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.baseCommit) + ? { baseCommit: projectCommitSummary(unit.baseCommit) } + : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; + } + return { + ...common, + kind: unit.kind === 'rewritten-same-patch' ? 'rewritten-same-patch' : 'retained', + reviewable: false, + ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), + ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), + }; +}; + +/** Project forge-internal stack evolution onto Core {@link ReviewCommitEvolution}. */ +export const projectCommitEvolution = (evolution: CommitStackEvolution): ReviewCommitEvolution => ({ + recommendation: { + rationale: evolution.recommendation.reason, + suggestedStructure: evolution.recommendation.structure, + }, + summary: evolution.summary, + units: evolution.units.map(projectEvolutionUnit), + ...(evolution.warnings ? { warnings: evolution.warnings } : {}), +}); diff --git a/gitlab/src/history.ts b/gitlab/src/history.ts index ce84c515..8d99dfd0 100644 --- a/gitlab/src/history.ts +++ b/gitlab/src/history.ts @@ -6,8 +6,6 @@ import type { DiffComparisonAnalysis, DiffComparisonView, ReviewCommitEvolution, - ReviewCommitSummary, - ReviewEvolutionUnit, ReviewPlan, ReviewVersionOption, } from '@nkzw/codiff-core/types'; @@ -16,6 +14,7 @@ import { diffComparison, diffComparisonView, diffRange, + projectCommitEvolution, resolveReviewPlan, reviewVersionOption, revisionRef, @@ -1569,137 +1568,7 @@ export const projectMergeRequestVersionRef = ( ...(version.previousNumber != null ? { previousNumber: version.previousNumber } : {}), }); -const projectCommitSummary = (commit: Algorithmish | undefined): ReviewCommitSummary | undefined => { - if (!commit) return undefined; - return { - authorName: commit.authorName, - authoredAt: commit.authoredAt, - parentIds: commit.parentIds ?? [], - sha: commit.sha, - shortSha: commit.shortSha, - subject: commit.subject, - webUrl: commit.webUrl, - ...(commit.diffStat ? { diffStat: commit.diffStat } : {}), - }; -}; - -type Algorithmish = { - authorName: string; - authoredAt: string; - diffStat?: { additions: number; deletions: number; filesChanged: number }; - parentIds?: ReadonlyArray; - sha: string; - shortSha: string; - subject: string; - webUrl?: string; -}; - -export const projectEvolutionUnit = (unit: { - after?: Algorithmish; - baseCommit?: Algorithmish; - before?: Algorithmish; - confidence: 'exact' | 'high' | 'unmatched'; - id: string; - kind: string; - matchReasons?: ReadonlyArray; - matchScore?: number; - order: number; - rebaseDrivers?: ReadonlyArray<{ - authorName: string; - authoredAt: string; - overlappingPaths: ReadonlyArray; - sha: string; - shortSha: string; - subject: string; - webUrl?: string; - }>; - reviewable: boolean; -}): ReviewEvolutionUnit => { - const common = { - confidence: unit.confidence, - id: unit.id, - order: unit.order, - ...(unit.matchReasons ? { matchReasons: unit.matchReasons } : {}), - ...(unit.matchScore != null ? { matchScore: unit.matchScore } : {}), - } as const; - if (unit.kind === 'added' || unit.kind === 'introduced') { - return { - ...common, - after: projectCommitSummary(unit.after)!, - kind: 'introduced', - reviewable: true, - }; - } - if (unit.kind === 'removed') { - return { - ...common, - before: projectCommitSummary(unit.before)!, - kind: 'removed', - reviewable: true, - }; - } - if (unit.kind === 'likely-revised' || unit.kind === 'revised') { - return { - ...common, - after: projectCommitSummary(unit.after)!, - before: projectCommitSummary(unit.before)!, - kind: 'revised', - reviewable: true, - ...(unit.rebaseDrivers - ? { - rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ - authorName: driver.authorName, - authoredAt: driver.authoredAt, - overlappingPaths: driver.overlappingPaths, - sha: driver.sha, - shortSha: driver.shortSha, - subject: driver.subject, - webUrl: driver.webUrl, - })), - } - : {}), - }; - } - if (unit.kind === 'ambiguous') { - return { - ...common, - kind: 'ambiguous', - reviewable: true, - ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), - ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), - }; - } - if (unit.kind === 'absorbed-into-base') { - return { - ...common, - kind: 'absorbed-into-base', - reviewable: false, - ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), - ...(projectCommitSummary(unit.baseCommit) ? { baseCommit: projectCommitSummary(unit.baseCommit) } : {}), - ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), - }; - } - // retained / rewritten-same-patch - return { - ...common, - kind: unit.kind === 'rewritten-same-patch' ? 'rewritten-same-patch' : 'retained', - reviewable: false, - ...(projectCommitSummary(unit.after) ? { after: projectCommitSummary(unit.after) } : {}), - ...(projectCommitSummary(unit.before) ? { before: projectCommitSummary(unit.before) } : {}), - }; -}; - -export const projectCommitEvolution = ( - evolution: MergeRequestVersionCommitEvolution, -): ReviewCommitEvolution => ({ - recommendation: { - rationale: evolution.recommendation.reason, - suggestedStructure: evolution.recommendation.structure, - }, - summary: evolution.summary, - units: evolution.units.map(projectEvolutionUnit), - ...(evolution.warnings ? { warnings: evolution.warnings } : {}), -}); +export { projectCommitEvolution, projectEvolutionUnit } from '@nkzw/codiff-core'; export const projectVersionCompare = ( compare: MergeRequestVersionCompare, From 4bcdf041e1da17ff8994c0a2f1fd48845cd53e32 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:31 -0500 Subject: [PATCH 11/20] Add shared version comparison UI Extend the existing shared review surface with commit and version navigation, whole-diff comparison state, review-history sidebars, and the controls needed to inspect changed files across review iterations. Keep the UI host-driven so Codiff Web can supply provider data without embedding GitLab or local Electron behavior in the components. --- core/App.css | 1111 ++++- core/SharedWalkthroughApp.tsx | 3758 ++++++++++++++++- core/__tests__/App.test.tsx | 11 + core/app/components/CommitRefTooltip.tsx | 83 + core/app/components/CommitScopePanel.tsx | 157 + core/app/components/ReviewCodeView.tsx | 148 +- core/app/components/Sidebar.tsx | 392 +- .../walkthrough/NarrativeSidebar.tsx | 182 +- .../walkthrough/NarrativeWalkthroughView.tsx | 255 +- .../walkthrough/WalkthroughProgress.tsx | 18 +- core/app/components/walkthrough/icons.tsx | 21 + core/index.ts | 21 + core/lib/diff.ts | 9 +- core/lib/narrative-walkthrough.ts | 26 +- core/react.ts | 35 + core/walkthrough.css | 260 +- 16 files changed, 6018 insertions(+), 469 deletions(-) create mode 100644 core/app/components/CommitRefTooltip.tsx create mode 100644 core/app/components/CommitScopePanel.tsx create mode 100644 core/app/components/walkthrough/icons.tsx diff --git a/core/App.css b/core/App.css index a3fc0d5c..57ea8dd4 100644 --- a/core/App.css +++ b/core/App.css @@ -835,6 +835,8 @@ a.review-top-bar-source:focus-visible { align-items: center; display: flex; flex: none; + height: 30px; + justify-content: center; width: auto; } @@ -910,6 +912,12 @@ a.review-top-bar-source:focus-visible { width: 6px; } +.app-shell > .sidebar, +.app-shell > .sidebar-resizer, +.app-shell > .review { + grid-row: 2; +} + .sidebar-toggle-button { -webkit-app-region: no-drag; align-items: center; @@ -948,12 +956,6 @@ a.review-top-bar-source:focus-visible { z-index: 21; } -.app-shell > .sidebar, -.app-shell > .sidebar-resizer, -.app-shell > .review { - grid-row: 2; -} - .sidebar-resizer::before { background: transparent; content: ''; @@ -1225,6 +1227,35 @@ html[data-codiff-platform='darwin'] .sidebar { background: var(--sidebar-vibrancy-tint); } +.sidebar-header { + -webkit-app-region: drag; + border-bottom: 1px solid var(--sidebar-border); + flex: none; + min-width: 0; + padding: 0 14px; +} + +.sidebar-path-row { + align-items: center; + display: flex; + gap: 8px; + min-height: 38px; + min-width: 0; + padding-left: 64px; +} + +.app-shell.window-fullscreen .sidebar-path-row { + padding-left: 0; +} + +.share-shell .sidebar-path-row { + padding-left: 0; +} + +.merge-request-shell .sidebar-path-row { + gap: 6px; +} + .merge-request-nav-button { align-items: center; background: transparent; @@ -1238,7 +1269,348 @@ html[data-codiff-platform='darwin'] .sidebar { justify-content: center; padding: 0; text-decoration: none; - width: 28px; + min-width: 28px; +} +.sidebar-version-compare-button { + padding-inline: 8px; + width: auto; +} + +.version-comparison-section { + display: grid; + gap: 8px; + min-width: 0; + padding: 8px 12px; +} + +.version-picker-pair { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.version-picker { + min-width: 0; + position: relative; +} + +.version-picker-label { + color: var(--muted); + display: block; + font: 10px/1.35 var(--font-mono); + margin-bottom: 3px; + text-transform: uppercase; +} + +.version-picker-trigger { + align-items: center; + background: rgb(127 127 127 / 0.08); + border: 1px solid rgb(127 127 127 / 0.18); + border-radius: 6px; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + font: 12px/1.35 var(--font-mono); + justify-content: space-between; + min-width: 0; + padding: 5px 7px; + width: 100%; +} + +.version-picker-positioner { + /* Base UI renders this in a portal, outside the sidebar's clipping context. */ + z-index: 1000; +} + +.version-picker-popover { + background: var(--code-bg); + border: 1px solid rgb(127 127 127 / 0.25); + border-radius: 7px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); + max-height: min(320px, 55vh); + min-width: min(560px, calc(100vw - 32px)); + overflow: auto; + padding: 3px; +} + +.version-picker-popover [role='listbox'] { + display: grid; +} + +.version-picker-option { + align-items: center; + background: transparent; + border: 0; + border-radius: 5px; + color: var(--sidebar-text); + cursor: pointer; + display: grid; + font: 11px/1.5 var(--font-mono); + gap: 5px; + grid-template-columns: 32px 36px minmax(54px, 1fr) auto auto auto minmax(110px, auto); + min-width: 0; + padding: 4px 5px; + text-align: left; + white-space: nowrap; +} + +.version-picker-option:hover, +.version-picker-option[data-highlighted], +.version-picker-option[data-selected] { + background: rgb(127 127 127 / 0.14); +} + +.version-picker-option[data-disabled] { + cursor: not-allowed; + opacity: 0.42; +} + +.version-picker-option code { + overflow: hidden; + text-overflow: ellipsis; +} + +.version-comparison-status { + align-items: center; + color: var(--muted); + display: flex; + font: 11px/1.4 var(--font-mono); + gap: 6px; + min-height: 24px; +} + +.version-comparison-status.error { color: var(--danger); } + +.version-comparison-spinner { + animation: version-comparison-spin 0.8s linear infinite; + border: 2px solid rgb(127 127 127 / 0.25); + border-radius: 50%; + border-top-color: var(--sidebar-text); + height: 12px; + width: 12px; +} + +@keyframes version-comparison-spin { to { transform: rotate(360deg); } } + +.version-picker-head { color: var(--accent, var(--sidebar-text)); font-weight: 700; } +.version-picker-additions { color: var(--diff-addition); } +.version-picker-deletions { color: var(--diff-deletion); } +.version-picker-timing { color: var(--muted); } + +.version-base-movement, +.version-commit-evolution, +.version-walkthrough-structure, +.version-unit-scope { + background: rgb(127 127 127 / 0.06); + border: 1px solid rgb(127 127 127 / 0.14); + border-radius: 7px; + color: var(--sidebar-text); + font: 11px/1.45 var(--font-sans); + padding: 7px; +} + +.version-base-movement a { color: inherit; font-family: var(--font-mono); } +.version-base-movement-stat, +.version-base-movement small, +.version-commit-evolution small, +.version-walkthrough-structure small { color: var(--muted); } +.version-base-movement-stat { + font: 11px/1.45 var(--font-sans); + letter-spacing: 0; + text-transform: none; +} +.version-base-movement small { display: block; margin-top: 3px; } +.version-base-movement-commits { margin-top: 6px; } +.version-base-movement-commits > summary { + color: var(--muted); + cursor: pointer; + font: 11px/1.45 var(--font-sans); + letter-spacing: 0; + list-style: none; + text-transform: none; + user-select: none; +} +.version-base-movement-commits > summary::-webkit-details-marker { display: none; } +.version-base-movement-commits > summary::before { + content: '▸'; + display: inline-block; + margin-right: 4px; + transition: transform 0.12s ease; +} +.version-base-movement-commits[open] > summary::before { transform: rotate(90deg); } +.version-base-movement-commit-list { + margin-top: 5px; + max-height: 220px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 2px; +} +.version-base-movement-commit { + color: inherit; + font: 11px/1.4 var(--font-sans); + letter-spacing: 0; + text-decoration: none; + text-transform: none; +} +.version-base-movement-commit:hover { + background: rgb(127 127 127 / 0.14); +} +.version-commit-evolution > strong { display: block; margin-bottom: 4px; } + +.version-commit-evolution-list { display: grid; gap: 1px; } +.version-commit-unit { + align-items: center; + background: transparent; + border: 0; + border-radius: 5px; + color: inherit; + cursor: pointer; + display: grid; + font: 10px/1.35 var(--font-sans); + gap: 5px; + grid-template-columns: 12px auto minmax(0, 1fr); + padding: 4px; + text-align: left; +} +.version-commit-unit:disabled { cursor: default; } +.version-commit-unit:not(:disabled):hover, +.version-commit-unit[aria-pressed='true'] { background: rgb(127 127 127 / 0.14); } +.version-commit-unit code { font-size: 9px; white-space: nowrap; } +.version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.version-commit-unit.unchanged { opacity: 0.5; } +.version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } +.version-commit-kind { font-weight: 700; } +.version-commit-kind.added { color: var(--diff-addition); } +.version-commit-kind.removed { color: var(--diff-deletion); } +.version-commit-kind.likely-revised, +.version-commit-kind.revised { + color: #d9962f; +} + +.version-commit-kind.introduced { + color: #3f9a5a; +} +.version-commit-kind.absorbed-into-base { color: var(--muted); } +.version-commit-kind.ambiguous { color: var(--muted); } +.version-commit-kind.unchanged { color: var(--muted); } +.version-commit-unit-block { display: grid; gap: 1px; } +.version-commit-rebase-drivers { + display: grid; + gap: 1px; + margin: 0 0 2px 12px; + padding-left: 6px; + border-left: 1px solid rgb(127 127 127 / 0.22); +} +.version-commit-rebase-drivers-label { + color: var(--muted); + font: 9px/1.3 var(--font-sans); + padding: 2px 4px 0; +} +.version-commit-rebase-drivers .version-commit-unit { + opacity: 0.88; +} + + +.version-unit-scope { align-items: center; display: flex; justify-content: space-between; } +.version-unit-scope button { background: transparent; border: 0; color: var(--accent, var(--sidebar-text)); cursor: pointer; font-size: 10px; } +.version-tree-commit-scope { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 5px; + padding: 6px 8px 8px; +} +.version-tree-commit-scope-header { + align-items: center; + display: flex; + justify-content: space-between; +} +.version-tree-commit-scope-header > span { + color: var(--muted); + font: 600 10px/1.35 var(--font-sans); +} +.version-tree-commit-scope-header button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font: 10px/1.35 var(--font-sans); + padding: 2px 0; +} +.version-tree-commit-scope-header button:disabled { + color: var(--muted); + cursor: default; + opacity: 0.55; +} +.version-tree-commit-options { + display: grid; + gap: 1px; + max-height: 190px; + overflow-y: auto; + overscroll-behavior: contain; +} +.version-tree-commit-options label { + align-items: center; + border-radius: 4px; + cursor: pointer; + display: grid; + font: 10px/1.35 var(--font-sans); + gap: 5px; + grid-template-columns: auto auto minmax(0, 1fr); + min-height: 25px; + padding: 3px 4px; +} +.version-tree-commit-options label:hover { + background: rgb(127 127 127 / 0.1); +} +.version-tree-commit-options input { + margin: 0; +} +.version-tree-commit-options code { + font: 9px/1.35 var(--font-mono); + white-space: nowrap; +} +.version-tree-commit-options span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.version-walkthrough-structure { display: grid; gap: 5px; margin-bottom: 10px; } +.version-walkthrough-structure label { align-items: center; display: flex; gap: 5px; } +.version-walkthrough-structure .sidebar-version-compare-button { margin-top: 3px; } + +@media (max-width: 480px) { + .version-picker-popover { min-width: calc(100vw - 24px); } + .version-picker-option { gap: 3px; grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; } + .version-picker-option { + grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto; + } + .version-picker-timing { grid-column: 1 / -1; padding-left: 60px; } +} +.sidebar-diff-scope-header { + align-items: center; + display: flex; + flex: none; + justify-content: space-between; + padding: 8px 8px 0; +} + +.sidebar-diff-scope-header > span, +.sidebar-scope-status { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} + +.sidebar-scope-status { + padding: 10px 8px; + text-transform: none; +} + +.sidebar-scope-status.error { + color: var(--danger); } .merge-request-nav-button:not(.merge-request-home-button):hover { @@ -1259,6 +1631,19 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 18px 14px; } +.sidebar-path { + color: var(--sidebar-text); + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sidebar-search-row { display: grid; flex: none; @@ -1266,6 +1651,95 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 10px 8px; } +.sidebar-mode-toggle { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 6px; + grid-template-columns: minmax(48px, 0.75fr) minmax(96px, 1.35fr) minmax(68px, 0.9fr); + padding: 0 8px; +} + +.share-shell .sidebar-mode-toggle { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.merge-request-shell .sidebar-mode-toggle { + grid-template-columns: minmax(42px, 0.65fr) minmax(88px, 1.05fr) minmax(102px, 1.3fr); +} +.sidebar-commit-selector { + display: grid; + flex: none; + gap: 4px; + padding: 4px 8px 8px; +} + +.sidebar-commit-selector span { + color: var(--muted); + font: 10px/1 var(--font-mono); + text-transform: uppercase; +} + +.sidebar-commit-selector select { + min-width: 0; + width: 100%; +} + +.sidebar-mode-toggle button { + -webkit-app-region: no-drag; + background: transparent; + border: 0; + border-radius: 24px 24px 0 0; + color: var(--muted); + corner-shape: squircle; + cursor: pointer; + display: inline-flex; + gap: 5px; + font: 600 12px/1.35 var(--font-sans); + height: 30px; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 8px; +} + +.sidebar-mode-toggle button span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-tab-count { + background: rgb(127 127 127 / 0.16); + border-radius: 999px; + color: var(--muted); + flex: none; + font: 700 10px/1 var(--font-mono); + min-width: 16px; + padding: 3px 5px; + text-align: center; +} + +.sidebar-mode-toggle button[aria-selected='true'] .sidebar-tab-count { + background: rgb(127 127 127 / 0.22); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button:hover:not(:disabled) { + background: rgb(127 127 127 / 0.1); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button[aria-selected='true'] { + background: rgb(127 127 127 / 0.14); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button:disabled { + cursor: default; + opacity: 0.45; +} + .sidebar-settings-bar { align-items: center; border-top: 1px solid var(--sidebar-border); @@ -1336,71 +1810,351 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--sidebar-text); } -.file-tree-shell, -.file-tree { - flex: 1; - display: block; - min-height: 0; +.file-tree-shell, +.file-tree { + flex: 1; + display: block; + min-height: 0; +} + +.file-tree { + height: 100%; +} + +.history-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; + min-height: 0; + overflow: auto; + padding: 8px 6px; +} + +.history-entry { + -webkit-app-region: no-drag; + background: transparent; + border: 0; + border-radius: 8px; + color: var(--text); + corner-shape: squircle; + cursor: pointer; + display: grid; + column-gap: 8px; + grid-template-columns: 72px minmax(0, 1fr); + padding: 3px 5px; + row-gap: 2px; + text-align: left; + width: 100%; +} + +.history-entry:hover, +.history-entry.selected { + background: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); +} + +.history-entry.selected { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); +} + +.history-section { + align-items: center; + color: var(--text); + display: flex; + font: 700 11px/1.25 var(--font-sans); + margin-top: 8px; + min-height: 20px; + overflow: hidden; + padding: 4px 10px 1px; + position: relative; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + z-index: 1; +} + +.history-list > .history-section:first-child { + margin-top: 0; +} + +/* Override the generic single-row history label layout. The comparison controls + * deliberately occupy stacked rows so status text never sits beside a picker. */ +.history-section.version-comparison-section { + align-items: stretch; + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 0; + grid-template-rows: auto minmax(0, 1fr); + max-height: min(46vh, 420px); + min-height: 0; + overflow: hidden; + padding: 0; + white-space: normal; +} + +.history-section.version-comparison-section.collapsed { + grid-template-rows: auto; + max-height: none; +} + +.version-comparison-header { + align-items: center; + display: grid; + gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + padding: 8px 10px 8px 8px; +} + +.version-comparison-toggle { + align-items: flex-start; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: grid; + gap: 6px; + grid-template-columns: 12px minmax(0, 1fr); + min-width: 0; + padding: 0; + text-align: left; +} + +.version-comparison-toggle-caret { + color: var(--muted); + font: 11px/1.35 var(--font-mono); + margin-top: 2px; +} + +.version-comparison-toggle-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.version-comparison-toggle-copy > strong { + color: var(--sidebar-text); + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.version-comparison-summary { + color: var(--muted); + font: 11px/1.35 var(--font-mono); + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.diff-scope-control { + border: 1px solid var(--sidebar-border); + border-radius: 6px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; +} + +.diff-scope-control button { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font: 600 10px/1.25 var(--font-sans); + min-width: 0; + padding: 5px 7px; + white-space: nowrap; +} + +.diff-scope-control button + button { + border-left: 1px solid var(--sidebar-border); +} + +.diff-scope-control button.selected { + background: color-mix(in srgb, var(--tree-selection-bg) 72%, transparent); + color: var(--sidebar-text); +} + +.diff-scope-control button:disabled { + cursor: default; +} + +.version-comparison-endpoint { + align-items: baseline; + display: inline-flex; + gap: 4px; +} + +.version-comparison-exit { + flex: none; +} + +.commit-scope-heading { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + display: flex; + flex-direction: column; + font: 600 10px/1.35 var(--font-sans); + gap: 0; + min-width: 0; + padding: 0; + text-align: left; +} + +.commit-scope-heading-label { + align-items: center; + display: inline-flex; + gap: 4px; +} + +.commit-scope-heading-label svg { + color: var(--muted); + transition: transform 120ms ease; +} + +.commit-scope-heading-label svg.collapsed { + transform: rotate(-90deg); +} + +.commit-scope-heading small { + color: var(--muted); + font: 9px/1.25 var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-comment-section { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; +} + +.sidebar-comment-section-toggle { + align-items: center; + background: transparent; + border: 0; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +.sidebar-comment-section-toggle > span { + align-items: baseline; + display: flex; + gap: 6px; + min-width: 0; +} + +.sidebar-comment-section-toggle strong { + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.sidebar-comment-section-toggle small { + color: var(--muted); + font: 11px/1.25 var(--font-mono); +} + +.sidebar-comment-section-toggle svg { + color: var(--muted); + flex: none; + transition: transform 120ms ease; +} + +.sidebar-comment-section-toggle svg.collapsed { + transform: rotate(-90deg); +} + +.sidebar-comment-section-body { + min-width: 0; } -.file-tree { - height: 100%; +.ai-review-drawer { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; } -.history-list { - display: flex; - flex-direction: column; - flex: 1; - gap: 2px; - min-height: 0; - overflow: auto; - padding: 8px 6px; +.sidebar-comments-preferences { + border-bottom: 1px solid var(--sidebar-border); + padding: 6px 10px; } -.history-entry { - -webkit-app-region: no-drag; +.ai-review-drawer-toggle { + align-items: center; background: transparent; border: 0; - border-radius: 8px; - color: var(--text); - corner-shape: squircle; + color: inherit; cursor: pointer; - display: grid; - column-gap: 8px; - grid-template-columns: 72px minmax(0, 1fr); - padding: 3px 5px; - row-gap: 2px; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; text-align: left; width: 100%; } -.history-entry:hover, -.history-entry.selected { - background: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); -} - -.history-entry.selected { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); +.ai-review-drawer-toggle > span:first-child { + display: grid; + gap: 2px; + min-width: 0; } -.history-section { - align-items: center; - color: var(--text); - display: flex; - font: 700 11px/1.25 var(--font-sans); - margin-top: 8px; - min-height: 20px; +.ai-review-drawer-toggle small { + color: var(--muted); overflow: hidden; - padding: 4px 10px 1px; - position: relative; text-overflow: ellipsis; - text-transform: uppercase; white-space: nowrap; - z-index: 1; } -.history-list > .history-section:first-child { - margin-top: 0; +.ai-review-drawer-body { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; + padding: 0 10px 10px; +} + +.ai-review-list.history-list { + flex: none; + max-height: 280px; + overflow: auto; + padding: 0; +} + +.ai-review-entry { + border-top: 1px solid var(--sidebar-border); + display: grid; + gap: 3px; + padding-top: 8px; +} + +.ai-review-entry small { + color: var(--muted); +} + +.ai-review-entry p { + margin: 0; + white-space: pre-wrap; +} + +.version-comparison-body { + display: grid; + gap: 8px; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 0 12px 10px; +} + +.version-comparison-section.collapsed .version-comparison-header { + padding-bottom: 8px; } .history-entry-subject { @@ -1540,6 +2294,17 @@ html[data-codiff-platform='darwin'] .sidebar { overflow: visible; } +.walkthrough-generating-main { + display: grid; + gap: 10px; + justify-items: center; +} + +.walkthrough-generating-main > span { + color: var(--muted); + font: 12px/1.4 var(--font-sans); +} + .sidebar-comment-list.history-list { padding-top: 6px; } @@ -1571,12 +2336,23 @@ html[data-codiff-platform='darwin'] .sidebar { } .walkthrough-list { + display: flex; flex: 1; + flex-direction: column; min-height: 0; - overflow: auto; + overflow: hidden; padding: 4px 4px 0; } +.walkthrough-list > .wt-focus { + flex: none; +} + +.walkthrough-list > .wt-toc-scroll { + flex: 1; + min-height: 0; +} + .walkthrough-inline-code { -webkit-box-decoration-break: clone; background: var(--inline-code-bg); @@ -1693,6 +2469,8 @@ html[data-codiff-platform='darwin'] .sidebar { flex: none; gap: 8px; justify-content: flex-end; + min-width: max-content; + white-space: nowrap; } .pull-request-merge-status-badge { @@ -1727,6 +2505,14 @@ html[data-codiff-platform='darwin'] .sidebar { overflow: clip; } +.codiff-source-description-panel:has(.review-submit-popover), +.codiff-source-description-overview-card:has(.review-submit-popover), +.codiff-source-description-overview-status:has(.review-submit-popover) { + overflow: visible; + position: relative; + z-index: 13; +} + .codiff-source-description-panel, .merge-request-comments-source-description { font-size: 14px; @@ -1752,6 +2538,35 @@ html[data-codiff-platform='darwin'] .sidebar { background: var(--code-bg); } +.codiff-source-description-overview { + align-items: start; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + padding: 11px 12px 12px; +} + +.codiff-source-description-overview-aside { + min-width: 0; +} + +.codiff-source-description-overview-aside { + display: flex; + flex-direction: column; + gap: 8px; +} + +.codiff-source-description-overview-card { + background: var(--code-bg); + border-radius: var(--codiff-diff-radius); + box-shadow: + 0 0 0 1px var(--file-border), + 6px 18px 80px -54px rgb(0 0 0 / 0.78); + corner-shape: squircle; + min-width: 0; + overflow: clip; +} + .codiff-source-description-footer { padding: 0 8px 8px 50px; } @@ -2011,6 +2826,21 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 11px 12px 48px; } +.merge-request-review-surface { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: hidden; + padding: 11px 12px 0; +} + +.merge-request-review-surface > :last-child { + flex: 1; + min-height: 0; +} + .merge-request-comments-source-description { --codiff-diff-radius: 28px; @@ -2700,6 +3530,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-file-comment-button, .codiff-markdown-button, .codiff-button, +.codiff-open-button, .codiff-viewed-button, .codiff-copy-path-button { -webkit-app-region: no-drag; @@ -2743,7 +3574,8 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--viewed); } -.codiff-button.plan-download-button { +.codiff-button.plan-download-button, +.codiff-open-button.plan-download-button { height: 30px; padding: 0; width: 30px; @@ -2795,6 +3627,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-file-comment-button:hover, .codiff-markdown-button:hover, .codiff-button:hover:not(:disabled), +.codiff-open-button:hover:not(:disabled), .codiff-viewed-button:hover { background: rgb(127 127 127 / 0.11); } @@ -2863,6 +3696,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-load-button, .codiff-markdown-button, .codiff-button, +.codiff-open-button, .codiff-viewed-button { border-radius: 14px; corner-shape: squircle; @@ -2899,14 +3733,92 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--diff-deletion); } +.diffstat-additions { + color: var(--diff-addition); +} + +.diffstat-deletions { + color: var(--diff-deletion); +} + +.git-commit-ref-trigger { + align-items: center; + color: var(--tree-selection-focus); + cursor: pointer; + display: inline-flex; + min-width: 0; + text-decoration: none; +} + +.git-commit-ref-trigger code { + color: inherit; + font: inherit; + white-space: nowrap; +} + +.git-commit-ref-trigger:focus-visible { + border-radius: 3px; + outline: 2px solid var(--tree-selection-focus); + outline-offset: 2px; +} + +.git-commit-tooltip-positioner { + z-index: 10000; +} + +.git-commit-tooltip { + background: var(--code-bg); + border: 1px solid var(--file-border); + border-radius: 6px; + box-shadow: 0 10px 28px rgb(0 0 0 / 0.22); + color: var(--text); + display: grid; + gap: 5px; + max-width: min(360px, calc(100vw - 24px)); + padding: 9px 10px; +} + +.git-commit-tooltip > strong { + font: 600 11px/1.4 var(--font-sans); + overflow-wrap: anywhere; +} + +.git-commit-tooltip-sha { + color: var(--muted); + font: 10px/1.4 var(--font-mono); + overflow-wrap: anywhere; +} + +.git-commit-tooltip-meta { + color: var(--muted); + font: 10px/1.4 var(--font-sans); +} + +.git-commit-tooltip-diffstat { + display: inline-flex; + font: 600 10px/1.4 var(--font-mono); + gap: 7px; +} + +.git-commit-tooltip-link { + align-items: center; + color: var(--tree-selection-focus); + display: inline-flex; + font: 600 10px/1.4 var(--font-sans); + gap: 5px; + margin-top: 2px; + text-decoration: none; + width: fit-content; +} + .codiff-status-badge.added, .codiff-status-badge.untracked, .codiff-section-badge.unstaged { - color: rgb(31 122 68); + color: var(--diff-addition); } .codiff-status-badge.deleted { - color: rgb(184 49 47); + color: var(--diff-deletion); } .codiff-status-badge.renamed, @@ -2917,6 +3829,8 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-markdown-button, .codiff-load-button, .codiff-file-comment-button, +.codiff-button, +.codiff-open-button, .codiff-viewed-button { border: 1px solid rgb(127 127 127 / 0.18); flex: none; @@ -2945,19 +3859,17 @@ html[data-codiff-platform='darwin'] .sidebar { opacity: 0.72; } -.codiff-button { +.codiff-button, +.codiff-open-button { box-shadow: 0 0 0 1px rgb(255 255 255 / 0) inset, 0 10px 32px -24px rgb(0 0 0 / 0), 0 8px 20px -16px rgb(0 0 0 / 0); - flex: none; - gap: 7px; transform: scale(1); transition: background 160ms ease, box-shadow 250ms ease, transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1); - white-space: nowrap; } .codiff-button-size-default { @@ -3010,18 +3922,21 @@ html[data-codiff-platform='darwin'] .sidebar { text-decoration: underline; } -.codiff-button:hover:not(:disabled) { +.codiff-button:hover:not(:disabled), +.codiff-open-button:hover:not(:disabled) { box-shadow: 0 10px 32px -24px rgb(0 0 0 / 0.92), 0 8px 20px -16px rgb(0 0 0 / 0.82); transform: scale(1.05); } -.codiff-button:active:not(:disabled):not(:has(.review-submit-toggle:active)) { +.codiff-button:active:not(:disabled):not(:has(.review-submit-toggle:active)), +.codiff-open-button:active:not(:disabled):not(:has(.review-submit-toggle:active)) { transform: scale(0.95); } -.codiff-button:disabled { +.codiff-button:disabled, +.codiff-open-button:disabled { color: var(--muted); cursor: default; opacity: 0.55; @@ -3621,9 +4536,11 @@ diffs-container .review-comment-thread { .review-submit-primary { gap: 7px; padding: 0 9px 0 11px; + white-space: nowrap; } .review-submit-toggle { + flex: none; width: 28px; } @@ -3679,6 +4596,7 @@ diffs-container .review-comment-thread { .review-submit-button.close { color: var(--text); + white-space: nowrap; } .review-submit-popover { @@ -3787,7 +4705,6 @@ diffs-container .review-comment-thread { .loading { color: var(--muted); font-family: var(--font-mono); - font-style: italic; font-size: 13px; transform: translateY(-12px); user-select: none; @@ -3903,6 +4820,7 @@ diffs-container .review-comment-thread { .codiff-load-button, .codiff-file-comment-button, .codiff-button, + .codiff-open-button, .codiff-viewed-button { padding-inline: 9px; } @@ -4177,3 +5095,68 @@ diffs-container .review-comment-thread { display: none; } } + +@container sidebar (max-width: 260px) { + .sidebar-mode-toggle { + gap: 4px; + grid-template-columns: minmax(42px, 0.75fr) minmax(82px, 1.35fr) minmax(58px, 0.9fr); + padding-inline: 8px; + } + + .merge-request-shell .sidebar-mode-toggle { + grid-template-columns: minmax(40px, 0.65fr) minmax(78px, 1.05fr) minmax(88px, 1.3fr); + } + + .sidebar-mode-toggle button { + font-size: 11px; + gap: 4px; + padding-inline: 4px; + } + + .sidebar-tab-count { + min-width: 14px; + padding-inline: 4px; + } +} + + +.walkthrough-regenerate-controls { + align-items: stretch !important; + display: grid !important; + gap: 6px !important; + padding: 8px 12px !important; + text-transform: none !important; + white-space: normal !important; +} +.walkthrough-regenerate-controls > span { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} +.walkthrough-regenerate-controls .merge-request-nav-button { + justify-content: flex-start; + min-height: 28px; + padding: 0 8px; + width: 100%; +} + + +.walkthrough-structure-controls { + align-items: stretch !important; + display: grid !important; + gap: 6px !important; + padding: 8px 12px !important; + text-transform: none !important; + white-space: normal !important; +} +.walkthrough-structure-controls > span { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} +.walkthrough-structure-controls .merge-request-nav-button { + justify-content: flex-start; + min-height: 28px; + padding: 0 8px; + width: 100%; +} diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index d2a0a018..987b0040 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -1,16 +1,30 @@ +import { Select } from '@base-ui/react/select'; +import { MarkdownEditor, type MarkdownEditorHandle } from '@nkzw/mdx-editor'; +import useRelativeTime from '@nkzw/use-relative-time'; import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; import { PathIcon as Path } from '@phosphor-icons/react/Path'; import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; -import { Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Button } from './app/components/Button.tsx'; -import { ReviewFileTree } from './app/components/FileTree.tsx'; +import type { FileTreeRowDecorationRenderer } from '@pierre/trees'; +import { FileTree, useFileTree } from '@pierre/trees/react'; +import { ChevronDown, Trash2, X } from 'lucide-react'; import { - MergeRequestCommentsView, - SidebarGeneralCommentList, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from 'react'; +import { Avatar } from './app/components/Avatar.tsx'; +import { Button } from './app/components/Button.tsx'; +import { CommitRefTooltip } from './app/components/CommitRefTooltip.tsx'; +import { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; import { isTerminalPullRequestMergeState, isPullRequestReviewActionDisabled, @@ -18,6 +32,7 @@ import { PullRequestMergeStatusBadge, PullRequestReviewButtons, } from './app/components/Panels.tsx'; +import { ReadOnlyMarkdownView } from './app/components/ReadOnlyMarkdownView.tsx'; import { PullRequestSourceDescription, ReviewCodeView, @@ -31,67 +46,101 @@ import { type WalkthroughBlockScrollTarget, } from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; -import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; -import { - getCodeFontLineHeight, - normalizeCodeFontSizePreference, - useDocumentAppearance, -} from './app/hooks/useDocumentAppearance.ts'; -import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; -import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; -import { useReviewFileState } from './app/hooks/useReviewState.ts'; import { createDefaultConfig } from './config/defaults.ts'; import { matchesShortcut } from './config/keymap.ts'; +import type { CodiffKeymap } from './config/types.ts'; import { getAgentLabel } from './lib/app-constants.ts'; -import type { CodeViewInstance, ReviewComment, ReviewScrollTarget } from './lib/app-types.ts'; +import type { + CodeViewInstance, + ReviewComment, + ReviewIdentity, + ReviewScrollTarget, +} from './lib/app-types.ts'; +import { DEFAULT_PADDING } from './lib/code-view-options.ts'; import { fileHasVisibleDiff, + formatTreeLineCount, getDiffLineCount, + getDiffLineCountTitle, + getFirstVisibleSection, + getItemId, getTotalDiffLineCount, isMarkdownFilePath, } from './lib/diff.ts'; -import { compactPath, fuzzyMatches, sortFiles } from './lib/files.ts'; +import { compactPath, fileTreeSort, fuzzyMatches, sortFiles, statusForTree } from './lib/files.ts'; import { isNativeInputTarget } from './lib/keyboard.ts'; import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; import { + getCommentKey, getPendingPullRequestReviewComments, getReviewCommentsFromState, - mergeReviewComments, - toSubmittedReviewComment, toPullRequestReviewComment, } from './lib/review-comments.ts'; -import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; import { - SIDEBAR_COLLAPSE_THRESHOLD, + evolutionUnitCommit, + evolutionUnitRebaseDrivers, + versionOptionHeadCommitId, + versionOptionLabelText, +} from './lib/review-history.ts'; +import { + updateReviewIdentityCollapsed, + updateReviewIdentityViewed, +} from './lib/review-identity.ts'; +import { SIDEBAR_DEFAULT_WIDTH, + clampSidebarWidth, readSidebarWidth, writeSidebarWidth, } from './lib/sidebar-width.ts'; -import { getSourceLabel, getSourceKey } from './lib/source.ts'; +import { getShortRef, getSourceLabel, getSourceKey } from './lib/source.ts'; import type { + ChangedFile, + CodiffPreferences, + DiffComparisonAnalysis, + DiffComparisonBaseMovement, + DiffComparisonCommentAssociation, + DiffComparisonView, GitIdentity, + NarrativeWalkthrough, + PullRequestAIReview, PullRequestMergeOptions, PullRequestGeneralComment, PullRequestGeneralCommentThread, PullRequestExistingReviewComment, PullRequestReviewComment, PullRequestReviewEvent, + ReviewAuthor, + ReviewCommitEvolution, + ReviewCommitListEntry, + ReviewCommitSummary, + ReviewEvolutionUnit, + ReviewRebaseDriverCommit, + ReviewStrategySummary, + ReviewVersionOption, RepositoryState, SharedWalkthroughSnapshot, WalkthroughCommitMessageResult, WalkthroughCommitResult, } from './types.ts'; -export { - ReadOnlyGeneralCommentCard, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; - const emptyReviewComments: ReadonlyArray = []; +const emptyExistingReviewComments: ReadonlyArray = []; const emptyGeneralCommentThreads: ReadonlyArray = []; const emptyPaths = new Set(); const emptyWalkthroughNotes = new Map(); +const showResolvedCommentsStorageKey = 'codiff:web-show-resolved-comments:v1'; +const walkthroughCodeViewBottomInset = 96; +const CODE_FONT_SIZE_DEFAULT = 13; +const defaultSharedPreferences: SharedWalkthroughSnapshot['preferences'] = { + codeFontFamily: 'Fira Code', + codeFontSize: CODE_FONT_SIZE_DEFAULT, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, +}; + const readSharedSidebarWidth = () => typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); @@ -101,8 +150,132 @@ const writeSharedSidebarWidth = (width: number) => { } }; -export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; -export type ReviewMode = 'comments' | 'tree' | 'walkthrough'; +export type MergeRequestWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; +export type MergeRequestReviewMode = 'comments' | 'commits' | 'tree' | 'walkthrough'; +export type ReviewWalkthroughStatus = MergeRequestWalkthroughStatus; +export type ReviewMode = MergeRequestReviewMode; + +// Optional host-supplied commit / version comparison surfaces for MR review. +// Hosts project provider history into Core contracts from `./types.ts`. +export type MergeRequestCommitListEntry = ReviewCommitListEntry; +export type MergeRequestReviewStrategySummary = ReviewStrategySummary; +export type MergeRequestVersionOption = ReviewVersionOption; +export type MergeRequestVersionCompareSummary = DiffComparisonAnalysis['summary']; +export type MergeRequestVersionCompareCommentAssociation = DiffComparisonCommentAssociation; +export type MergeRequestVersionBaseMovementCommit = NonNullable< + DiffComparisonBaseMovement['commits'] +>[number]; +export type MergeRequestVersionBaseMovement = DiffComparisonBaseMovement; +export type MergeRequestVersionCommitSummary = ReviewCommitSummary; +export type MergeRequestVersionRebaseDriverCommit = ReviewRebaseDriverCommit; +export type MergeRequestVersionCommitEvolutionUnit = ReviewEvolutionUnit; +export type MergeRequestVersionCommitEvolution = ReviewCommitEvolution; +export type MergeRequestVersionCompareView = DiffComparisonView; + +const versionOptionLabel = versionOptionLabelText; +const versionOptionHeadSha = versionOptionHeadCommitId; + +const versionOptionHeadUrl = (version: ReviewVersionOption, projectUrl?: string) => + version.range.head.label.url ?? + (projectUrl + ? `${projectUrl}/-/commit/${encodeURIComponent(version.range.head.commitId)}` + : undefined); + +export type SharedWalkthroughCommenting = { + canComment: boolean; + onDeleteComment: (commentId: string) => Promise; + onDeleteGeneralComment: (commentId: string) => Promise; + onReplyGeneralComment: (threadId: string, body: string) => Promise; + onResolveDiscussion: (discussionId: string, resolved: boolean) => Promise; + onSignIn: () => Promise | void; + onSubmitComment: (comment: PullRequestReviewComment) => Promise; + onSubmitGeneralComment: (body: string) => Promise; + onUpdateComment: (commentId: string, body: string) => Promise; + onUpdateGeneralComment: (commentId: string, body: string) => Promise; +}; +export type ReviewCommenting = SharedWalkthroughCommenting; + +export type MergeRequestReviewAppProps = { + aiReviews?: ReadonlyArray; + commentsError?: string | null; + commentsLoading?: boolean; + commits?: ReadonlyArray; + externalUrl: string; + gitIdentity?: GitIdentity | null; + initialMode?: MergeRequestReviewMode; + onCancelAutoMerge?: () => Promise | void; + onClosePullRequest?: () => Promise | void; + onExitVersionCompare?: () => void; + onGenerateWalkthrough: (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => Promise | void; + onHome: () => void; + onLoadCommitDiff?: ( + sha: string, + ) => Promise> | ReadonlyArray; + onLoadVersionCommitDiff?: ( + unitId: string, + ) => Promise> | ReadonlyArray; + onMergePullRequest?: ( + options: PullRequestMergeOptions & { autoMerge: boolean }, + ) => Promise | void; + onModeChange?: (mode: MergeRequestReviewMode) => void; + onOpenVersionCompare?: (options?: { commentId?: string }) => void; + onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; + onSubmitComment: (comment: PullRequestReviewComment) => Promise; + onSubmitGeneralComment: (body: string) => Promise; + onSubmitReview: ( + event: PullRequestReviewEvent, + comments: ReadonlyArray, + body?: string, + ) => Promise; + onUpdateComment: (commentId: string, body: string) => Promise; + onUpdateDescription?: (body: string) => Promise | void; + onUpdateGeneralComment: (commentId: string, body: string) => Promise; + onUpdateTitle?: (title: string) => Promise | void; + onUploadDescriptionAsset?: (file: File) => Promise | string; + onVersionCompareRangeChange?: (fromId: string, toId: string) => void; + onVersionWalkthroughStructureChange?: (structure: 'commit-by-commit' | 'whole-diff') => void; + preferences?: Partial< + Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + > + >; + reviewStrategy?: MergeRequestReviewStrategySummary | null; + selectedCommitSha?: string | null; + settingsBar?: ReactNode; + sourceDescriptionFooterAside?: ReactNode; + state: RepositoryState; + title: string; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError?: string | null; + versionCommitEvolutionLoading?: boolean; + versionCompare?: MergeRequestVersionCompareView | null; + versionCompareEnabled?: boolean; + versionCompareError?: string | null; + versionCompareFromId?: string | null; + versionCompareLoading?: boolean; + versionCompareToId?: string | null; + versionHistoryLoading?: boolean; + versions?: ReadonlyArray; + versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; + walkthrough: NarrativeWalkthrough | null; + walkthroughError?: string | null; + walkthroughStatus: MergeRequestWalkthroughStatus; +}; + +const getCodeFontLineHeight = (size: number) => Math.round((size * 20) / 13); + +const normalizeCodeFontSizePreference = (size: number) => + Number.isFinite(size) ? Math.min(32, Math.max(10, Math.round(size))) : CODE_FONT_SIZE_DEFAULT; const getSnapshotReviewComments = ( snapshot: SharedWalkthroughSnapshot, @@ -124,6 +297,893 @@ const getSnapshotReviewComments = ( const noop = () => {}; +const getAuthorDisplayName = (author: ReviewAuthor) => author.name || author.login; +const getGeneralCommentElementId = (commentId: string) => `general-comment:${commentId}`; + +const scrollCommentIntoContainerView = (container: HTMLElement, element: HTMLElement) => { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const top = + container.scrollTop + + elementRect.top - + containerRect.top - + Math.max(0, (container.clientHeight - elementRect.height) / 2); + + container.scrollTo({ + behavior: 'smooth', + top, + }); +}; +const plainTextCommentPattern = + /|<\/?(?:details|summary)\b[^>]*>|```[\s\S]*?```|`([^`]+)`|\[([^\]]+)\]\([^)]+\)|[*_~>#]+/g; + +const getCommentPreview = (body: string) => { + const preview = body + .replaceAll( + plainTextCommentPattern, + (_, inlineCode: string | undefined, linkText: string | undefined) => + inlineCode ?? linkText ?? ' ', + ) + .replaceAll(/\s+/g, ' ') + .trim(); + return preview || 'Comment'; +}; + +const getAIReviewDecisionLabel = (decision: PullRequestAIReview['decision']) => { + switch (decision) { + case 'approved': + return 'Approved'; + case 'approved-with-comments': + return 'Approved with comments'; + case 'changes-requested': + return 'Changes requested'; + default: + return 'Decision unavailable'; + } +}; + +const formatSubmittedAt = (value: string) => { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toLocaleString() : value; +}; + +function RelativeSubmittedAtTime({ + submittedAt, + timestamp, +}: { + submittedAt: string; + timestamp: number; +}) { + const relativeTime = useRelativeTime(timestamp); + return ( + + ); +} + +function SubmittedAtTime({ submittedAt }: { submittedAt: string }) { + const timestamp = Date.parse(submittedAt); + if (!Number.isFinite(timestamp)) { + return ( + + ); + } + return ; +} + +export function ReadOnlyGeneralCommentCard({ + className = '', + comment, + focused = false, +}: { + className?: string; + comment: PullRequestGeneralComment; + focused?: boolean; +}) { + const displayName = getAuthorDisplayName(comment.author); + const classes = ['review-comment', 'general-comment-card', focused ? 'focused' : '', className] + .filter(Boolean) + .join(' '); + + return ( +
+ +
+
+ {displayName} + {comment.submittedAt ? : null} +
+ } + value={comment.body} + variant="embedded" + /> +
+
+ ); +} + +function GeneralCommentCard({ + comment, + editDraft, + editError, + editing, + editSubmitting, + focused, + keymap, + onCancelEdit, + onChangeEditDraft, + onDelete, + onSaveEdit, + onStartEdit, +}: { + comment: PullRequestGeneralComment; + editDraft: string; + editError: string | null; + editing: boolean; + editSubmitting: boolean; + focused: boolean; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeEditDraft: (draft: string) => void; + onDelete: (commentId: string) => void; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; +}) { + const displayName = getAuthorDisplayName(comment.author); + const canSaveEdit = editing && !editSubmitting && Boolean(editDraft.trim()); + const editorRef = useRef(null); + const handleEditKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!matchesShortcut(event, keymap, 'submitComment') || !canSaveEdit) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onSaveEdit(); + }, + [canSaveEdit, keymap, onSaveEdit], + ); + const setEditorRef = useCallback( + (editor: MarkdownEditorHandle | null) => { + editorRef.current = editor; + if (editor && editing) { + requestAnimationFrame(() => { + editor.focus({ defaultSelection: 'rootEnd', preventScroll: true }); + }); + } + }, + [editing], + ); + + useEffect(() => { + if (!editing) { + return; + } + + requestAnimationFrame(() => { + editorRef.current?.focus({ defaultSelection: 'rootEnd', preventScroll: true }); + }); + }, [editing]); + + return ( +
+ +
+
+ {displayName} + {comment.submittedAt ? : null} + {editing ? ( + + + + + ) : ( + <> + {comment.canEdit ? ( + + ) : null} + {comment.canDelete ? ( + + ) : null} + + )} +
+ {editing ? ( + <> + }> + + + {editError ?
{editError}
: null} + + ) : ( + } + value={comment.body} + variant="embedded" + /> + )} +
+
+ ); +} + +function GeneralCommentThreadCard({ + canComment, + editDraft, + editError, + editingCommentId, + editSubmitting, + focusedCommentId, + keymap, + onCancelEdit, + onChangeEditDraft, + onDelete, + onReply, + onResolve, + onSaveEdit, + onStartEdit, + thread, +}: { + canComment: boolean; + editDraft: string; + editError: string | null; + editingCommentId: string | null; + editSubmitting: boolean; + focusedCommentId: string | null; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeEditDraft: (draft: string) => void; + onDelete: (commentId: string) => void; + onReply: (threadId: string, body: string) => Promise; + onResolve: (threadId: string, resolved: boolean) => Promise; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; + thread: PullRequestGeneralCommentThread; +}) { + const [replyDraft, setReplyDraft] = useState(''); + const [replyError, setReplyError] = useState(null); + const [replying, setReplying] = useState(false); + const [showReply, setShowReply] = useState(false); + const [resolving, setResolving] = useState(false); + const resolved = thread.isResolved === true; + const submitReply = useCallback(() => { + const body = replyDraft.trim(); + if (!body || replying) { + return; + } + setReplyError(null); + setReplying(true); + void onReply(thread.id, body) + .then(() => { + setReplyDraft(''); + setShowReply(false); + }) + .catch((error: unknown) => { + setReplyError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setReplying(false)); + }, [onReply, replyDraft, replying, thread.id]); + const toggleResolved = useCallback(() => { + if (resolving) { + return; + } + setResolving(true); + void onResolve(thread.id, !resolved) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setResolving(false)); + }, [onResolve, resolved, resolving, thread.id]); + + return ( +
+ {thread.comments.map((comment) => ( + + ))} + {thread.canReply && canComment && !resolved ? ( + showReply ? ( + + ) : ( +
+ +
+ ) + ) : null} + {thread.canResolve ? ( +
+ +
+ ) : null} +
+ ); +} + +type SidebarOverviewComment = { + canResolve: boolean; + comment: PullRequestGeneralComment; + isResolved: boolean; +}; + +function SidebarCommentSection({ + children, + count, + title, +}: { + children: ReactNode; + count: number; + title: string; +}) { + const [expanded, setExpanded] = useState(true); + return ( +
+ + {expanded ?
{children}
: null} +
+ ); +} + +function SidebarOverviewCommentList({ + comments, + focusedCommentId, + onActivateComment, +}: { + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; +}) { + return ( +
+ {comments.map(({ canResolve, comment, isResolved }, index) => { + const displayName = getAuthorDisplayName(comment.author); + const selected = comment.id === focusedCommentId; + return ( + + ); + })} +
+ ); +} + +function SidebarCodeCommentList({ + commentAssociations, + comments, + focusedCommentId, + onActivateComment, + onOpenVersionCompareForComment, +}: { + commentAssociations: ReadonlyMap; + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; + onOpenVersionCompareForComment?: (commentId: string) => void; +}) { + if (comments.length === 0) { + return
No code comments.
; + } + return ( +
+ {comments.map((comment, index) => { + const association = commentAssociations.get(comment.id); + const versionLabel = comment.versionLabel; + return ( + + ); + })} +
+ ); +} + +function AIReviewDrawer({ + commentsError, + commentsLoading, + onSelectReview, + reviews, + selectedReviewId, +}: { + commentsError: string | null; + commentsLoading: boolean; + onSelectReview: (reviewId: string) => void; + reviews: ReadonlyArray; + selectedReviewId: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const orderedReviews = reviews.toSorted( + (first, second) => Date.parse(second.submittedAt ?? '') - Date.parse(first.submittedAt ?? ''), + ); + const latest = orderedReviews[0]; + + return ( +
+ + {expanded ? ( +
+ {commentsError ?
{commentsError}
: null} + {reviews.length === 0 && !commentsLoading ? ( + No AI review records. + ) : ( +
+ {orderedReviews.map((review, index) => ( + + ))} +
+ )} +
+ ) : null} +
+ ); +} + +function GeneralCommentComposer({ + disabled, + draft, + error, + gitIdentity, + keymap, + onChangeDraft, + onSubmit, + submitting, +}: { + disabled: boolean; + draft: string; + error: string | null; + gitIdentity: GitIdentity | null; + keymap: CodiffKeymap; + onChangeDraft: (draft: string) => void; + onSubmit: () => void; + submitting: boolean; +}) { + const canSubmit = !disabled && !submitting && Boolean(draft.trim()); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!matchesShortcut(event, keymap, 'submitComment') || !canSubmit) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onSubmit(); + }, + [canSubmit, keymap, onSubmit], + ); + return ( +
+
+ +
+
+ {gitIdentity?.name || gitIdentity?.email || 'You'} + +
+ }> + + + {error ?
{error}
: null} +
+
+
+ ); +} + +function AIReviewCommentCard({ review }: { review: PullRequestAIReview }) { + const reviewerName = getAuthorDisplayName(review.reviewer); + return ( +
+ +
+
+ {reviewerName} + + {getAIReviewDecisionLabel(review.decision)} + {' · '} + {review.versionLabel ?? review.reviewedHeadSha?.slice(0, 7) ?? 'Unknown version'} + + {review.submittedAt ? : null} +
+ } + value={review.body} + variant="embedded" + /> +
+
+ ); +} + +function MergeRequestCommentsView({ + aiReview, + canComment, + commenting, + commentsError, + commentsLoading, + draft, + editDraft, + editError, + editingCommentId, + editSubmitting, + error, + focusedCommentId, + focusedCommentRequest, + gitIdentity, + keymap, + onCancelEdit, + onChangeDraft, + onChangeEditDraft, + onSaveEdit, + onStartEdit, + onSubmit, + sourceDescription, + submitting, + threads, +}: { + aiReview: PullRequestAIReview | null; + canComment: boolean; + commenting?: SharedWalkthroughCommenting; + commentsError: string | null; + commentsLoading: boolean; + draft: string; + editDraft: string; + editError: string | null; + editingCommentId: string | null; + editSubmitting: boolean; + error: string | null; + focusedCommentId: string | null; + focusedCommentRequest: number; + gitIdentity: GitIdentity | null; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeDraft: (draft: string) => void; + onChangeEditDraft: (draft: string) => void; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; + onSubmit: () => void; + sourceDescription?: ReactNode; + submitting: boolean; + threads: ReadonlyArray; +}) { + const commentsRef = useRef(null); + useEffect(() => { + if (focusedCommentId == null) { + return; + } + + const container = commentsRef.current; + const element = document.getElementById(getGeneralCommentElementId(focusedCommentId)); + if (!container || !element) { + return; + } + + scrollCommentIntoContainerView(container, element); + }, [focusedCommentId, focusedCommentRequest]); + + return ( +
+ {commentsLoading ? ( +
+ Loading comments… +
+ ) : commentsError ? ( +
{commentsError}
+ ) : null} + {sourceDescription ? ( +
{sourceDescription}
+ ) : null} + {aiReview ? : null} + {threads.length > 0 ? ( +
+ {threads.map((thread) => ( + { + void commenting?.onDeleteGeneralComment(commentId).catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }); + }} + onReply={(threadId, body) => + commenting?.onReplyGeneralComment(threadId, body) ?? + Promise.reject(new Error('Replying is unavailable.')) + } + onResolve={(threadId, resolved) => + commenting?.onResolveDiscussion(threadId, resolved) ?? + Promise.reject(new Error('Resolving is unavailable.')) + } + onSaveEdit={onSaveEdit} + onStartEdit={onStartEdit} + thread={thread} + /> + ))} +
+ ) : !aiReview ? ( +
+
+ No comments yet + Add a comment to start the discussion. +
+
+ ) : null} + {canComment ? ( + + ) : commenting ? ( +
+ +
+ ) : null} +
+ ); +} + const disabledCommit = async (): Promise => ({ reason: 'Shared walkthroughs are read-only.', status: 'failed', @@ -134,19 +1194,168 @@ const disabledCommitMessage = async (): Promise status: 'unavailable', }); +function SharedFileTree({ + files, + onActivatePath, + selectedPath, + showWhitespace, +}: { + files: ReadonlyArray; + onActivatePath: (path: string) => void; + selectedPath: string | null; + showWhitespace: boolean; +}) { + const paths = useMemo(() => files.map((file) => file.path), [files]); + const filePathSet = useMemo(() => new Set(paths), [paths]); + const lineCountsByPath = useMemo( + () => new Map(files.map((file) => [file.path, getDiffLineCount(file, showWhitespace)])), + [files, showWhitespace], + ); + const lineCountsByPathRef = useRef(lineCountsByPath); + const renderTreeRowDecoration = useCallback(({ item }) => { + const lineCount = lineCountsByPathRef.current.get(item.path); + return lineCount?.countable + ? { + text: formatTreeLineCount(lineCount), + title: getDiffLineCountTitle(lineCount), + } + : null; + }, []); + const status = useMemo( + () => + files.map((file) => ({ + path: file.path, + status: statusForTree[file.status], + })), + [files], + ); + const { model } = useFileTree({ + flattenEmptyDirectories: true, + gitStatus: status, + initialExpansion: 'open', + initialSelectedPaths: selectedPath ? [selectedPath] : [], + itemHeight: 30, + paths, + renderRowDecoration: renderTreeRowDecoration, + sort: fileTreeSort, + unsafeCSS: ` + :host { + --trees-bg-override: transparent; + --trees-bg-muted-override: var(--hover-wash); + --trees-border-color-override: var(--sidebar-border); + --trees-fg-muted-override: var(--muted); + --trees-fg-override: var(--sidebar-text); + --trees-focus-ring-color-override: var(--tree-selection-focus); + --trees-padding-inline-override: 4px; + --trees-search-bg-override: rgb(127 127 127 / 0.1); + --trees-search-fg-override: var(--sidebar-text); + --trees-selected-bg-override: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); + --trees-selected-fg-override: var(--sidebar-text); + --trees-selected-focused-border-color-override: color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); + --truncate-marker-background-color: transparent; + color-scheme: var(--codiff-tree-color-scheme, light dark); + color: var(--sidebar-text); + font: 13px/1.35 var(--font-sans); + } + + button[data-type='item'] { + background-color: transparent; + border-radius: 14px; + corner-shape: squircle; + } + + [data-item-section='decoration'] { + color: var(--muted); + font: 600 10px/1 var(--font-mono); + letter-spacing: 0; + } + `, + }); + + useLayoutEffect(() => { + lineCountsByPathRef.current = lineCountsByPath; + if (model.getFileTreeContainer()) { + model.render({}); + } + }, [lineCountsByPath, model]); + + useEffect(() => { + model.resetPaths(paths); + }, [model, paths]); + + useEffect(() => { + model.setGitStatus(status); + }, [model, status]); + + useEffect(() => { + if (!selectedPath) { + return; + } + + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + model.getItem(selectedPath)?.select(); + }, [model, selectedPath]); + + const handleTreeClick = useCallback( + (event: MouseEvent) => { + for (const target of event.nativeEvent.composedPath()) { + if (!('getAttribute' in target) || typeof target.getAttribute !== 'function') { + continue; + } + + const path = target.getAttribute('data-item-path'); + if (path && filePathSet.has(path)) { + onActivatePath(path); + return; + } + } + }, + [filePathSet, onActivatePath], + ); + + return ( +
+ +
+ ); +} + export type ReviewSurfaceProps = { - commenting?: ReviewCommenting; + aiReviews?: ReadonlyArray; + commenting?: SharedWalkthroughCommenting; + commentsError?: string | null; + commentsLoading?: boolean; + commits?: ReadonlyArray; externalUrl?: string; gitIdentity?: GitIdentity | null; - initialMode?: ReviewMode; + initialMode?: MergeRequestReviewMode; interactive?: { onCancelAutoMerge?: () => Promise | void; onClosePullRequest?: () => Promise | void; - onGenerateWalkthrough: () => Promise | void; + onExitVersionCompare?: () => void; + onGenerateWalkthrough: (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => Promise | void; onHome: () => void; + onLoadCommitDiff?: ( + sha: string, + ) => Promise> | ReadonlyArray; + onLoadVersionCommitDiff?: ( + unitId: string, + ) => Promise> | ReadonlyArray; onMergePullRequest?: ( options: PullRequestMergeOptions & { autoMerge: boolean }, ) => Promise | void; + onOpenVersionCompare?: (options?: { commentId?: string }) => void; onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; onSubmitComment: ( comment: PullRequestReviewComment, @@ -162,35 +1371,388 @@ export type ReviewSurfaceProps = { onUpdateGeneralComment: (commentId: string, body: string) => Promise; onUpdateTitle?: (title: string) => Promise | void; onUploadDescriptionAsset?: (file: File) => Promise | string; + onVersionCompareRangeChange?: (fromId: string, toId: string) => void; + reviewStrategy?: MergeRequestReviewStrategySummary | null; walkthroughError?: string | null; - walkthroughStatus: ReviewWalkthroughStatus; + walkthroughStatus: MergeRequestWalkthroughStatus; }; onDeleteShare?: () => Promise | void; - onModeChange?: (mode: ReviewMode) => void; + onModeChange?: (mode: MergeRequestReviewMode) => void; + onVersionWalkthroughStructureChange?: (structure: 'commit-by-commit' | 'whole-diff') => void; providerLabel?: string; repositoryUrl?: string; + selectedCommitSha?: string | null; settingsBar?: ReactNode; signInLabel?: string; snapshot: SharedWalkthroughSnapshot; sourceDescriptionFooterAside?: ReactNode; title?: string; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError?: string | null; + versionCommitEvolutionLoading?: boolean; + versionCompare?: MergeRequestVersionCompareView | null; + versionCompareEnabled?: boolean; + versionCompareError?: string | null; + versionCompareFromId?: string | null; + versionCompareLoading?: boolean; + versionCompareToId?: string | null; + versionHistoryLoading?: boolean; + versions?: ReadonlyArray; + versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; +}; + +const shortRelativeTime = (value: string) => { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return ''; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return 'just now'; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + if (days < 30) { + return `${days}d ago`; + } + return `${Math.floor(days / 30)}mo ago`; +}; + +const shortVersionAge = (value: string) => { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return '—'; + } + const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / (60 * 1000))); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + const days = Math.floor(hours / 24); + if (days < 14) { + return `${days}d`; + } + if (days < 60) { + return `${Math.floor(days / 7)}w`; + } + if (days < 365) { + return `${Math.floor(days / 30)}mo`; + } + return `${Math.floor(days / 365)}y`; }; +export const formatVersionElapsedDuration = (from: string, to: string) => { + const fromTimestamp = Date.parse(from); + const toTimestamp = Date.parse(to); + if ( + !Number.isFinite(fromTimestamp) || + !Number.isFinite(toTimestamp) || + toTimestamp < fromTimestamp + ) { + return '—'; + } + const minutes = Math.floor((toTimestamp - fromTimestamp) / (60 * 1000)); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + const days = Math.floor(hours / 24); + if (days < 14) { + return `${days}d`; + } + if (days < 60) { + return `${Math.floor(days / 7)}w`; + } + if (days < 365) { + return `${Math.floor(days / 30)}mo`; + } + return `${Math.floor(days / 365)}y`; +}; + +export const resolveVersionWalkthroughFiles = ({ + commitFiles, + structure, + versionFiles, +}: { + commitFiles: ReadonlyArray | null | undefined; + structure: 'commit-by-commit' | 'whole-diff'; + versionFiles: ReadonlyArray; +}): ReadonlyArray => + structure === 'commit-by-commit' ? (commitFiles ?? []) : versionFiles; + +export const combineVersionUnitFiles = ( + unitIds: ReadonlyArray, + filesByUnit: Readonly>>, +): ReadonlyArray => { + const filesByPath = new Map(); + for (const unitId of unitIds) { + for (const file of filesByUnit[unitId] ?? []) { + const existing = filesByPath.get(file.path); + if (!existing) { + filesByPath.set(file.path, { ...file }); + continue; + } + filesByPath.set(file.path, { + ...existing, + fingerprint: `${existing.fingerprint}:${file.fingerprint}`, + oldPath: existing.oldPath ?? file.oldPath, + sections: [...existing.sections, ...file.sections], + status: existing.status === file.status ? existing.status : 'modified', + }); + } + } + return [...filesByPath.values()]; +}; + +const formatSignedBaseInterval = (delta: number | null) => { + if (delta == null) { + return null; + } + const duration = formatVersionElapsedDuration( + new Date(0).toISOString(), + new Date(Math.abs(delta)).toISOString(), + ); + return `new base is ${duration} ${delta >= 0 ? 'newer' : 'older'}`; +}; + +const formatBaseMovementRelationship = ( + relationship: DiffComparisonBaseMovement['relationship'], +) => { + switch (relationship) { + case 'forward': + return 'fast-forward'; + case 'backward': + return 'rewound'; + case 'divergent': + return 'divergent histories'; + default: + return 'relationship unknown'; + } +}; + +const formatBaseMovementCommitCount = ( + movement: Pick, +) => { + const listed = movement.commits?.length ?? 0; + const count = movement.commitsBetween ?? (listed > 0 ? listed : null); + if (count == null) { + return 'Commit count unavailable'; + } + const approximate = movement.truncated || (movement.commitsBetween == null && listed > 0); + return `${approximate ? '≈' : ''}${count} commit${count === 1 ? '' : 's'}`; +}; + +const VersionPicker = ({ + gitLabProjectUrl, + label, + onChange, + otherId, + value, + versions, +}: { + gitLabProjectUrl?: string; + label: string; + onChange: (id: string) => void; + otherId: string | null; + value: string; + versions: ReadonlyArray; +}) => { + const selected = versions.find((version) => version.id === value); + return ( + { + if (nextValue) { + onChange(nextValue); + } + }} + value={value} + > +
+ {label} + + + {() => v{selected?.number ?? selected?.range.head.label.text ?? '—'}} + + + +
+ + + + + {versions.map((version) => { + const disabled = version.id === otherId; + const stat = version.diffStat; + const createdTimestamp = version.createdAt + ? new Date(version.createdAt).toLocaleString() + : 'MR base'; + const previousTimestamp = version.previousCreatedAt + ? new Date(version.previousCreatedAt).toLocaleString() + : null; + const age = version.number === 0 ? null : shortVersionAge(version.createdAt); + const elapsed = + version.previousCreatedAt && version.previousNumber != null + ? formatVersionElapsedDuration(version.previousCreatedAt, version.createdAt) + : null; + const timing = [ + age ? `${age} old` : null, + elapsed ? `${elapsed} since v${version.previousNumber}` : null, + ] + .filter(Boolean) + .join(' · '); + const title = [ + `v${version.number ?? version.range.head.label.text}`, + version.isHead ? 'HEAD' : '', + versionOptionHeadSha(version), + createdTimestamp, + previousTimestamp && version.previousNumber != null + ? `${elapsed} since v${version.previousNumber} (${previousTimestamp})` + : '', + ] + .filter(Boolean) + .join(' · '); + return ( + + + v{version.number ?? version.range.head.label.text} + + {version.isHead ? 'HEAD' : ''} + {version.number === 0 ? ( + base + ) : ( + + )} + + {version.number === 0 ? '' : `+${stat?.additions ?? '…'}`} + + + {version.number === 0 ? '' : `−${stat?.deletions ?? '…'}`} + + + {version.number === 0 + ? '' + : `${stat?.filesChanged ?? '…'} ${stat?.filesChanged === 1 ? 'file' : 'files'}`} + + + {version.number === 0 ? 'MR base' : timing || '—'} + + + ); + })} + + + + +
+ ); +}; + +function VersionComparisonEndpoint({ + gitLabProjectUrl, + version, +}: { + gitLabProjectUrl?: string; + version: MergeRequestVersionOption | null | undefined; +}) { + if (!version) { + return Version; + } + const versionLabel = versionOptionLabel(version); + const headSha = versionOptionHeadSha(version); + return ( + + {versionLabel} + + + ); +} + export function ReviewSurface({ + aiReviews = [], commenting, + commentsError = null, + commentsLoading = false, + commits = [], externalUrl, gitIdentity = null, initialMode, interactive, onDeleteShare, onModeChange, + onVersionWalkthroughStructureChange, providerLabel = 'provider', repositoryUrl, + selectedCommitSha = null, settingsBar, signInLabel = 'Sign in to comment', snapshot, sourceDescriptionFooterAside, title, + versionCommitEvolution = null, + versionCommitEvolutionError = null, + versionCommitEvolutionLoading = false, + versionCompare = null, + versionCompareEnabled = false, + versionCompareError = null, + versionCompareFromId = null, + versionCompareLoading = false, + versionCompareToId = null, + versionHistoryLoading = false, + versions = [], + versionWalkthroughStructure: versionWalkthroughStructureProp, }: ReviewSurfaceProps) { const canComment = commenting?.canComment ?? Boolean(interactive); const deleteShare = useCallback(async () => { @@ -212,6 +1774,144 @@ export function ReviewSurface({ const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; const updateGeneralDiscussion = commenting?.onUpdateGeneralComment ?? interactive?.onUpdateGeneralComment; + const [selectedPath, setSelectedPath] = useState( + () => snapshot.files[0]?.path ?? null, + ); + const [selectedVersionUnitIds, setSelectedVersionUnitIds] = useState>( + () => new Set(), + ); + const [versionUnitFiles, setVersionUnitFiles] = useState< + Readonly>> + >({}); + const [versionUnitLoadingIds, setVersionUnitLoadingIds] = useState>( + () => new Set(), + ); + const [versionUnitErrors, setVersionUnitErrors] = useState>>({}); + const versionUnitScopeRef = useRef(0); + const selectedVersionUnits = useMemo( + () => + (versionCommitEvolution?.units ?? []).filter((unit) => selectedVersionUnitIds.has(unit.id)), + [selectedVersionUnitIds, versionCommitEvolution], + ); + const selectedVersionUnitFiles = useMemo( + () => + combineVersionUnitFiles( + selectedVersionUnits.map((unit) => unit.id), + versionUnitFiles, + ), + [selectedVersionUnits, versionUnitFiles], + ); + const versionUnitLoading = selectedVersionUnits.some((unit) => + versionUnitLoadingIds.has(unit.id), + ); + const versionUnitError = selectedVersionUnits + .map((unit) => versionUnitErrors[unit.id]) + .find((error): error is string => Boolean(error)); + const [versionWalkthroughStructureState, setVersionWalkthroughStructureState] = useState< + 'commit-by-commit' | 'whole-diff' + >('whole-diff'); + const versionWalkthroughStructure = + versionWalkthroughStructureProp ?? versionWalkthroughStructureState; + const setVersionWalkthroughStructure = (structure: 'commit-by-commit' | 'whole-diff') => { + setVersionWalkthroughStructureState(structure); + onVersionWalkthroughStructureChange?.(structure); + }; + const [versionSectionExpanded, setVersionSectionExpanded] = useState(true); + + useEffect(() => { + setSelectedVersionUnitIds(new Set()); + setVersionUnitFiles({}); + setVersionUnitLoadingIds(new Set()); + setVersionUnitErrors({}); + versionUnitScopeRef.current += 1; + }, [versionCompare?.from.id, versionCompare?.to.id]); + + useEffect(() => { + if (!versionCommitEvolution) { + return; + } + if (versionWalkthroughStructureProp == null) { + setVersionWalkthroughStructureState(versionCommitEvolution.recommendation.suggestedStructure); + onVersionWalkthroughStructureChange?.( + versionCommitEvolution.recommendation.suggestedStructure, + ); + } + }, [ + onVersionWalkthroughStructureChange, + versionCommitEvolution, + versionWalkthroughStructureProp, + ]); + + const loadVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + if (versionUnitFiles[unit.id] || versionUnitLoadingIds.has(unit.id)) { + return; + } + if (!interactive?.onLoadVersionCommitDiff) { + return; + } + const scope = versionUnitScopeRef.current; + setVersionUnitLoadingIds((current) => new Set([...current, unit.id])); + setVersionUnitErrors((current) => { + const { [unit.id]: _error, ...rest } = current; + return rest; + }); + void Promise.resolve(interactive.onLoadVersionCommitDiff(unit.id)) + .then((files) => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitFiles((current) => ({ ...current, [unit.id]: files })); + setSelectedPath((current) => current ?? files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitErrors((current) => ({ + ...current, + [unit.id]: error instanceof Error ? error.message : String(error), + })); + }) + .finally(() => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitLoadingIds((current) => { + const next = new Set(current); + next.delete(unit.id); + return next; + }); + }); + }, + [interactive, versionUnitFiles, versionUnitLoadingIds], + ); + const toggleVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + const selected = selectedVersionUnitIds.has(unit.id); + const next = new Set(selectedVersionUnitIds); + if (selected) { + next.delete(unit.id); + } else { + next.add(unit.id); + loadVersionUnit(unit); + } + setSelectedVersionUnitIds(next); + }, + [loadVersionUnit, selectedVersionUnitIds], + ); + const selectOnlyVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + setSelectedVersionUnitIds(new Set([unit.id])); + setSelectedPath(null); + loadVersionUnit(unit); + }, + [loadVersionUnit], + ); + const clearVersionUnits = useCallback(() => { + setSelectedVersionUnitIds(new Set()); + setSelectedPath(versionCompare?.files[0]?.path ?? null); + }, [versionCompare?.files]); const resolveDiscussion = commenting?.onResolveDiscussion ?? interactive?.onResolveDiscussion; const sharedWalkthrough = useMemo( () => ({ @@ -220,17 +1920,60 @@ export function ReviewSurface({ }), [snapshot.walkthrough], ); + const [activeCommitSha, setActiveCommitSha] = useState( + () => selectedCommitSha ?? null, + ); + const [selectedTreeCommitShas, setSelectedTreeCommitShas] = useState>(() => + selectedCommitSha ? new Set([selectedCommitSha]) : new Set(), + ); + const [walkthroughCommitSha, setWalkthroughCommitSha] = useState(null); + const [commitFilesBySha, setCommitFilesBySha] = useState< + Readonly>> + >({}); + const [commitDiffError, setCommitDiffError] = useState(null); + const [commitDiffLoading, setCommitDiffLoading] = useState(false); + const [treeCommitDiffError, setTreeCommitDiffError] = useState(null); + const [treeCommitDiffLoading, setTreeCommitDiffLoading] = useState(false); + const commitLoadRequestRef = useRef(0); + // CBC walkthroughs are authored against unit-scoped commitFiles. Whole-diff + // walkthroughs are authored against the aggregate version comparison. + const walkthroughFiles = useMemo( + () => + versionCompare + ? resolveVersionWalkthroughFiles({ + commitFiles: sharedWalkthrough.commitFiles, + structure: versionWalkthroughStructure, + versionFiles: versionCompare.files, + }) + : walkthroughCommitSha + ? (commitFilesBySha[walkthroughCommitSha] ?? []) + : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], + [ + commitFilesBySha, + sharedWalkthrough.commitFiles, + snapshot.files, + versionCompare, + walkthroughCommitSha, + ], + ); const navigation = useNarrativeNavigation( sharedWalkthrough, - snapshot.files, - `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, + walkthroughFiles, + `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}:${versionCompare?.from.id ?? ''}:${versionCompare?.to.id ?? ''}`, ); const keymap = useMemo(() => createDefaultConfig().keymap, []); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [expandedGenerated, setExpandedGenerated] = useState>(() => new Set()); const [fileSearchQuery, setFileSearchQuery] = useState(''); - const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( + const [itemVersionByKey, setItemVersionByKey] = useState>({}); + const [sidebarWidth, setSidebarWidth] = useState(readSharedSidebarWidth); + const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( () => initialMode ?? (interactive ? 'tree' : 'walkthrough'), ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const isSidebarModeControlled = Boolean(initialMode && onModeChange); + const sidebarMode = + isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || !matchesShortcut(event, keymap, 'toggleSidebar')) { @@ -243,29 +1986,40 @@ export function ReviewSurface({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [keymap]); - const isSidebarModeControlled = Boolean(initialMode && onModeChange); - const sidebarMode = - isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; + useEffect(() => { + if (sidebarMode !== 'walkthrough' || navigation.mode !== 'stop') { + return; + } + const stop = navigation.walkthroughView?.sequence[navigation.index]; + const chapter = navigation.walkthroughView?.chapters.find( + (candidate) => candidate.id === stop?.chapterId, + ); + if (!versionCompare) { + const commitSha = + chapter?.commit?.sha ?? + commits.find((commit) => chapter?.id.startsWith(`${commit.sha}:`))?.sha; + setWalkthroughCommitSha(commitSha ?? null); + } + }, [ + navigation.index, + navigation.mode, + navigation.walkthroughView, + sidebarMode, + commits, + versionCompare, + ]); const [treeScrollTarget, setTreeScrollTarget] = useState(null); - const { - bumpItemVersion, - collapsed, - expandedGenerated, - itemVersionByKey, - selectedPath, - setSelectedPath, - toggleCollapsed, - toggleViewed, - viewed, - } = useReviewFileState({ - initialSelectedPath: snapshot.files[0]?.path ?? null, - }); - const { resizeSidebar, sidebarWidth } = useResizableSidebar({ - collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, - onCollapse: () => setSidebarCollapsed(true), - onWidthCommit: writeSharedSidebarWidth, - readWidth: readSharedSidebarWidth, - }); + const [viewed, setViewed] = useState>({}); + const walkthroughGenerationOptionsRef = useRef<{ + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + } | null>(null); const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< Readonly> @@ -282,37 +2036,79 @@ export function ReviewSurface({ ); const [localReviewComments, setLocalReviewComments] = useState>(emptyReviewComments); - const reviewComments = useMemo( - () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), + const [showResolvedComments, setShowResolvedComments] = useState(() => { + if (typeof localStorage === 'undefined') { + return true; + } + return localStorage.getItem(showResolvedCommentsStorageKey) !== 'false'; + }); + useEffect(() => { + localStorage.setItem(showResolvedCommentsStorageKey, String(showResolvedComments)); + }, [showResolvedComments]); + const allReviewComments = useMemo( + () => [...visibleSnapshotReviewComments, ...localReviewComments], [localReviewComments, visibleSnapshotReviewComments], ); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - createComment, - deleteComment: deleteLocalComment, - focusCommentId, - focusCommentRequest, - reviewCommentsRef, - updateActiveReviewCommentDraft, - updateComment, - } = useReviewCommentDrafts({ - canCreateComment: canComment, - comments: reviewComments, - onCommentFileChange: bumpItemVersion, - setComments: setLocalReviewComments, - }); - const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; - const generalComments = useMemo( + const reviewComments = useMemo( + () => + showResolvedComments + ? allReviewComments + : allReviewComments.filter((comment) => comment.isThreadResolved !== true), + [allReviewComments, showResolvedComments], + ); + const sidebarCodeComments = useMemo( + () => + (snapshot.reviewComments ?? emptyExistingReviewComments).filter( + (comment) => showResolvedComments || comment.isThreadResolved !== true, + ), + [showResolvedComments, snapshot.reviewComments], + ); + const reviewCommentsRef = useRef(reviewComments); + const generalCommentThreads = useMemo( + () => + (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).filter( + (thread) => showResolvedComments || thread.isResolved !== true, + ), + [showResolvedComments, snapshot.repository.generalComments], + ); + const overviewComments = useMemo( () => - (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( - (thread) => thread.comments, + generalCommentThreads.flatMap((thread) => + thread.comments.map((comment) => ({ + canResolve: thread.canResolve === true, + comment, + isResolved: thread.isResolved === true, + })), ), - [snapshot.repository.generalComments], + [generalCommentThreads], + ); + const generalComments = useMemo( + () => overviewComments.map(({ comment }) => comment), + [overviewComments], ); const generalCommentCount = generalComments.length; - const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); + const orderedAIReviews = useMemo( + () => + aiReviews.toSorted( + (first, second) => + Date.parse(second.submittedAt ?? '') - Date.parse(first.submittedAt ?? ''), + ), + [aiReviews], + ); + const [selectedAIReviewId, setSelectedAIReviewId] = useState( + () => orderedAIReviews[0]?.id ?? null, + ); + useEffect(() => { + setSelectedAIReviewId((current) => + orderedAIReviews.some((review) => review.id === current) + ? current + : (orderedAIReviews[0]?.id ?? null), + ); + }, [orderedAIReviews]); + const selectedAIReview = + orderedAIReviews.find((review) => review.id === selectedAIReviewId) ?? + orderedAIReviews[0] ?? + null; const [generalCommentDraft, setGeneralCommentDraft] = useState(''); const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); @@ -322,24 +2118,94 @@ export function ReviewSurface({ const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); + const [focusCommentId, setFocusCommentId] = useState(null); + const [focusCommentRequest, setFocusCommentRequest] = useState(0); + const [activeReviewCommentDraftState, setActiveReviewCommentDraftState] = useState | null>(null); const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = useState(null); const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); const walkthroughRequestPendingRef = useRef(false); + const walkthroughRequestForceRef = useRef(false); + const lastAutoVersionWalkthroughKeyRef = useRef(null); + const [walkthroughRequestForce, setWalkthroughRequestForce] = useState(false); const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); + const activeReviewCommentDraftRef = useRef | null>(null); const interactiveRef = useRef(interactive); + useEffect(() => { + reviewCommentsRef.current = reviewComments; + }, [reviewComments]); + + const activeCommit = useMemo( + () => commits.find((commit) => commit.sha === activeCommitSha) ?? null, + [activeCommitSha, commits], + ); + const activeCommitFiles = activeCommitSha ? (commitFilesBySha[activeCommitSha] ?? null) : null; + const selectedTreeCommitFiles = useMemo( + () => combineVersionUnitFiles([...selectedTreeCommitShas], commitFilesBySha), + [commitFilesBySha, selectedTreeCommitShas], + ); + const versionCompareActive = + versionCompareEnabled === true || + versionCompare != null || + versionCompareLoading || + Boolean(versionCompareError); + const versionCompareChangedPaths = useMemo(() => { + const files = + sidebarMode === 'walkthrough' && versionCompare + ? walkthroughFiles + : selectedVersionUnitIds.size > 0 + ? selectedVersionUnitFiles + : (versionCompare?.files ?? []); + return new Set(files.map((file) => file.path)); + }, [ + selectedVersionUnitFiles, + selectedVersionUnitIds.size, + sidebarMode, + versionCompare, + walkthroughFiles, + ]); + const versionCompareWalkthroughOptions = useMemo( + () => + versionCompare + ? { + versionCompare: { + fromId: versionCompare.from.id, + toId: versionCompare.to.id, + walkthroughStructure: versionWalkthroughStructure, + }, + } + : undefined, + [versionCompare, versionWalkthroughStructure], + ); + const reviewFiles = versionCompare + ? selectedVersionUnitIds.size > 0 + ? selectedVersionUnitFiles + : versionCompare.files + : sidebarMode === 'tree' && selectedTreeCommitShas.size > 0 + ? selectedTreeCommitFiles + : snapshot.files; const visibleFiles = useMemo( () => - sortFiles(snapshot.files).filter( + sortFiles(reviewFiles).filter( (file) => - fuzzyMatches(file.path, fileSearchQuery) && + fuzzyMatches(file.path, sidebarMode === 'tree' ? fileSearchQuery : '') && fileHasVisibleDiff(file, snapshot.preferences.showWhitespace), ), - [fileSearchQuery, snapshot.files, snapshot.preferences.showWhitespace], + [fileSearchQuery, reviewFiles, sidebarMode, snapshot.preferences.showWhitespace], ); + const commentAssociationById = useMemo(() => { + const map = new Map(); + for (const association of versionCompare?.analysis.commentAssociations ?? []) { + map.set(association.commentId, association); + } + return map; + }, [versionCompare]); const totalLineCount = useMemo( () => getTotalDiffLineCount( @@ -347,7 +2213,101 @@ export function ReviewSurface({ ), [snapshot.preferences.showWhitespace, visibleFiles], ); - const showTotalLineCount = sidebarMode !== 'comments' && totalLineCount.countable; + const showTotalLineCount = + sidebarMode !== 'comments' && sidebarMode !== 'commits' && totalLineCount.countable; + + useEffect(() => { + if (selectedCommitSha) { + setActiveCommitSha(selectedCommitSha); + setSelectedTreeCommitShas(new Set([selectedCommitSha])); + } + }, [selectedCommitSha]); + + const commitDiffTargetSha = + sidebarMode === 'walkthrough' || sidebarMode === 'commits' + ? (walkthroughCommitSha ?? activeCommitSha) + : null; + useEffect(() => { + if ( + (sidebarMode !== 'commits' && sidebarMode !== 'walkthrough') || + !commitDiffTargetSha || + !interactive?.onLoadCommitDiff + ) { + return; + } + if (commitFilesBySha[commitDiffTargetSha]) { + return; + } + const requestId = commitLoadRequestRef.current + 1; + commitLoadRequestRef.current = requestId; + setCommitDiffLoading(true); + setCommitDiffError(null); + void Promise.resolve(interactive.onLoadCommitDiff(commitDiffTargetSha)) + .then((files) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitFilesBySha((current) => ({ ...current, [commitDiffTargetSha]: files })); + setSelectedPath(files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitDiffError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (commitLoadRequestRef.current === requestId) { + setCommitDiffLoading(false); + } + }); + }, [commitDiffTargetSha, commitFilesBySha, interactive, sidebarMode]); + + useEffect(() => { + if ( + sidebarMode !== 'tree' || + versionCompareActive || + selectedTreeCommitShas.size === 0 || + !interactive?.onLoadCommitDiff + ) { + return; + } + const missingShas = [...selectedTreeCommitShas].filter((sha) => !commitFilesBySha[sha]); + if (missingShas.length === 0) { + return; + } + let cancelled = false; + setTreeCommitDiffLoading(true); + setTreeCommitDiffError(null); + void Promise.all( + missingShas.map((sha) => + Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), + ), + ) + .then((results) => { + if (cancelled) { + return; + } + setCommitFilesBySha((current) => ({ + ...current, + ...Object.fromEntries(results.map(({ files, sha }) => [sha, files])), + })); + setSelectedPath((current) => current ?? results[0]?.files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (!cancelled) { + setTreeCommitDiffError(error instanceof Error ? error.message : String(error)); + } + }) + .finally(() => { + if (!cancelled) { + setTreeCommitDiffLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [commitFilesBySha, interactive, selectedTreeCommitShas, sidebarMode, versionCompareActive]); const visibleSelectedPath = selectedPath && visibleFiles.some((file) => file.path === selectedPath) ? selectedPath @@ -368,20 +2328,103 @@ export function ReviewSurface({ ); }, [snapshot.files]); - useDocumentAppearance({ - codeFontFamily: snapshot.preferences.codeFontFamily, - codeFontSize: snapshot.preferences.codeFontSize, - theme: snapshot.preferences.theme, - }); + useEffect(() => { + const root = document.documentElement; + if (snapshot.preferences.theme === 'system') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', snapshot.preferences.theme); + } + }, [snapshot.preferences.theme]); + + useEffect(() => { + const root = document.documentElement; + const codeFontFamily = snapshot.preferences.codeFontFamily.trim(); + const codeFontSize = normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize); + + if (codeFontFamily) { + root.style.setProperty('--font-diff-mono', `${JSON.stringify(codeFontFamily)}, monospace`); + } + + root.style.setProperty('--font-diff-size', `${codeFontSize}px`); + root.style.setProperty('--font-diff-line-height', `${getCodeFontLineHeight(codeFontSize)}px`); + }, [snapshot.preferences.codeFontFamily, snapshot.preferences.codeFontSize]); + + const bumpItemVersion = useCallback((key: string) => { + setItemVersionByKey((current) => ({ + ...current, + [key]: (current[key] ?? 0) + 1, + })); + }, []); + const changeSidebarMode = useCallback( + (mode: MergeRequestReviewMode) => { + setUncontrolledSidebarMode(mode); + onModeChange?.(mode); + }, + [onModeChange], + ); + + const selectCommit = useCallback( + (sha: string) => { + if (versionCompareActive) { + interactive?.onExitVersionCompare?.(); + } + setActiveCommitSha(sha); + setSelectedTreeCommitShas(new Set([sha])); + setCommitDiffError(null); + setTreeCommitDiffError(null); + changeSidebarMode('tree'); + }, + [changeSidebarMode, versionCompareActive, interactive], + ); + + const createComment = useCallback( + (comment: Omit) => { + if (!canComment) { + return; + } + + const emptyExistingComment = reviewCommentsRef.current.find( + (candidate) => + candidate.body.length === 0 && getCommentKey(candidate) === getCommentKey(comment), + ); + if (emptyExistingComment) { + setFocusCommentId(emptyExistingComment.id); + setFocusCommentRequest((current) => current + 1); + return; + } + + const emptyDraft = reviewCommentsRef.current.find( + (candidate) => !candidate.isReadOnly && candidate.body.length === 0, + ); + if (emptyDraft) { + const id = crypto.randomUUID(); + setFocusCommentId(id); + setFocusCommentRequest((current) => current + 1); + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === emptyDraft.id + ? { + ...comment, + body: '', + id, + } + : candidate, + ), + ); + bumpItemVersion(emptyDraft.filePath); + bumpItemVersion(comment.filePath); + return; + } - const changeSidebarMode = useCallback( - (mode: ReviewMode) => { - setUncontrolledSidebarMode(mode); - onModeChange?.(mode); + const id = crypto.randomUUID(); + setFocusCommentId(id); + setFocusCommentRequest((current) => current + 1); + setLocalReviewComments((current) => [...current, { ...comment, body: '', id }]); + bumpItemVersion(comment.filePath); }, - [onModeChange], + [bumpItemVersion, canComment], ); - const activateGeneralComment = useCallback( (commentId: string) => { changeSidebarMode('comments'); @@ -390,6 +2433,14 @@ export function ReviewSurface({ }, [changeSidebarMode], ); + const activateReviewComment = useCallback( + (commentId: string) => { + changeSidebarMode('tree'); + setFocusCommentId(commentId); + setFocusCommentRequest((current) => current + 1); + }, + [changeSidebarMode], + ); const navigateGeneralComment = useCallback( (direction: 1 | -1) => { if (generalComments.length === 0) { @@ -413,6 +2464,31 @@ export function ReviewSurface({ }, [activateGeneralComment, focusedGeneralCommentId, generalComments], ); + const updateComment = useCallback((commentId: string, body: string) => { + const applyCommentBody = (comments: ReadonlyArray) => + comments.map((comment) => + comment.id === commentId && !comment.isReadOnly ? { ...comment, body } : comment, + ); + + reviewCommentsRef.current = applyCommentBody(reviewCommentsRef.current); + setLocalReviewComments(applyCommentBody); + }, []); + const updateActiveReviewCommentDraft = useCallback( + (comment: Pick | null) => { + activeReviewCommentDraftRef.current = comment; + setActiveReviewCommentDraftState((current) => { + if (comment == null) { + return current == null ? current : null; + } + + const body = comment.body.trim().length > 0 ? 'pending' : ''; + return current?.id === comment.id && current.body === body + ? current + : { body, id: comment.id }; + }); + }, + [], + ); const updateExistingReviewComment = useCallback( async (commentId: string, body: string) => { if (!updateReviewComment) { @@ -425,24 +2501,27 @@ export function ReviewSurface({ bumpItemVersion(comment.filePath); } }, - [bumpItemVersion, reviewCommentsRef, updateReviewComment], + [bumpItemVersion, updateReviewComment], ); const deleteComment = useCallback( (commentId: string) => { const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + updateActiveReviewCommentDraft(null); if (comment?.isReadOnly && comment.canDelete && commenting?.onDeleteComment) { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((candidate) => candidate.id !== commentId), - ); void commenting.onDeleteComment(commentId).catch((error: unknown) => { window.alert(error instanceof Error ? error.message : String(error)); }); return; } - deleteLocalComment(commentId); + setFocusCommentId((current) => (current === commentId ? null : current)); + setLocalReviewComments((current) => + current.filter((candidate) => candidate.id !== commentId), + ); + if (comment) { + bumpItemVersion(comment.filePath); + } }, - [commenting, deleteLocalComment, reviewCommentsRef, updateActiveReviewCommentDraft], + [bumpItemVersion, commenting, updateActiveReviewCommentDraft], ); const submitComment = useCallback( (commentId: string) => { @@ -465,18 +2544,11 @@ export function ReviewSurface({ : candidate, ), ); - void submitReviewComment( - toPullRequestReviewComment(comment, { includeSectionId: commenting != null }), - ) - .then((submittedComment) => { - clearCommentFocus(commentId); + void submitReviewComment(toPullRequestReviewComment(comment)) + .then(() => { + setFocusCommentId((current) => (current === commentId ? null : current)); setLocalReviewComments((current) => - current.flatMap((candidate) => { - if (candidate.id !== commentId) { - return [candidate]; - } - return [toSubmittedReviewComment(submittedComment, candidate)]; - }), + current.filter((candidate) => candidate.id !== commentId), ); bumpItemVersion(comment.filePath); }) @@ -497,14 +2569,7 @@ export function ReviewSurface({ bumpItemVersion(comment.filePath); }); }, - [ - bumpItemVersion, - clearCommentFocus, - commenting, - reviewCommentsRef, - submitReviewComment, - updateActiveReviewCommentDraft, - ], + [bumpItemVersion, submitReviewComment, updateActiveReviewCommentDraft], ); const submitReview = useCallback( (event: PullRequestReviewEvent, body?: string) => { @@ -527,9 +2592,7 @@ export function ReviewSurface({ } const pendingIds = new Set(pendingComments.map((comment) => comment.id)); setPullRequestReviewSubmitting(event); - const formattedComments = pendingComments.map((comment) => - toPullRequestReviewComment(comment), - ); + const formattedComments = pendingComments.map(toPullRequestReviewComment); const submission = body ? interactive.onSubmitReview(event, formattedComments, body) : interactive.onSubmitReview(event, formattedComments); @@ -550,8 +2613,6 @@ export function ReviewSurface({ interactive, pullRequestReviewSubmitting, snapshot.repository.source, - activeReviewCommentDraftRef, - reviewCommentsRef, updateActiveReviewCommentDraft, ], ); @@ -611,38 +2672,94 @@ export function ReviewSurface({ } let cancelled = false; - void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough()) + const options = walkthroughGenerationOptionsRef.current; + walkthroughGenerationOptionsRef.current = null; + void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough(options ?? undefined)) .catch(() => {}) .finally(() => { if (cancelled) { return; } walkthroughRequestPendingRef.current = false; + walkthroughRequestForceRef.current = false; setWalkthroughRequestPending(false); + setWalkthroughRequestForce(false); }); return () => { cancelled = true; }; }, [walkthroughRequestId, walkthroughRequestPending]); - const startWalkthroughGeneration = useCallback(() => { - if ( - !interactive || - interactive.walkthroughStatus === 'generating' || - walkthroughRequestPendingRef.current - ) { - return; - } + const startWalkthroughGeneration = useCallback( + (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + if ( + !interactive || + interactive.walkthroughStatus === 'generating' || + walkthroughRequestPendingRef.current + ) { + return; + } - walkthroughRequestPendingRef.current = true; - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); - }, [interactive]); + walkthroughGenerationOptionsRef.current = options ?? null; + walkthroughRequestPendingRef.current = true; + walkthroughRequestForceRef.current = options?.force === true; + setWalkthroughRequestForce(options?.force === true); + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, + [interactive], + ); useEffect(() => { - if (sidebarMode === 'walkthrough' && interactive?.walkthroughStatus === 'idle') { + if (sidebarMode !== 'walkthrough') { + return; + } + if (versionCompareActive) { + if (!versionCompareWalkthroughOptions) { + return; + } + const key = [ + versionCompareWalkthroughOptions.versionCompare.fromId, + versionCompareWalkthroughOptions.versionCompare.toId, + versionCompareWalkthroughOptions.versionCompare.walkthroughStructure, + ].join(':'); + // Auto-resolve whenever the version range/structure changes, including cache hits. + // Skip while a request is already in flight or actively generating. + if ( + lastAutoVersionWalkthroughKeyRef.current === key && + (interactive?.walkthroughStatus === 'ready' || + interactive?.walkthroughStatus === 'generating' || + walkthroughRequestPending) + ) { + return; + } + if (interactive?.walkthroughStatus === 'generating' || walkthroughRequestPending) { + return; + } + lastAutoVersionWalkthroughKeyRef.current = key; + startWalkthroughGeneration(versionCompareWalkthroughOptions); + return; + } + if (interactive?.walkthroughStatus === 'idle') { startWalkthroughGeneration(); } - }, [interactive?.walkthroughStatus, sidebarMode, startWalkthroughGeneration]); + }, [ + interactive?.walkthroughStatus, + sidebarMode, + startWalkthroughGeneration, + versionCompareActive, + versionCompareWalkthroughOptions, + walkthroughRequestPending, + ]); + useEffect(() => { if (sidebarMode !== 'comments' || generalComments.length === 0) { return; @@ -729,30 +2846,126 @@ export function ReviewSurface({ generalCommentEditSubmitting, updateGeneralDiscussion, ]); - const activateTreePath = useCallback( - (path: string) => { - setSelectedPath(path); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - path, - request: (current?.request ?? 0) + 1, - })); + const resizeSidebar = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + + const handle = event.currentTarget; + const shell = handle.parentElement; + if (!shell) { + return; + } + + const shellLeft = shell.getBoundingClientRect().left; + handle.setPointerCapture(event.pointerId); + handle.classList.add('dragging'); + document.body.style.cursor = 'col-resize'; + + const cleanup = () => { + handle.releasePointerCapture(event.pointerId); + handle.removeEventListener('pointermove', handleMove); + handle.removeEventListener('pointerup', handleEnd); + handle.removeEventListener('pointercancel', handleEnd); + handle.classList.remove('dragging'); + document.body.style.cursor = ''; + }; + + const handleMove = (moveEvent: PointerEvent) => { + setSidebarWidth(clampSidebarWidth(moveEvent.clientX - shellLeft)); + }; + + const handleEnd = () => { + cleanup(); + setSidebarWidth((width) => { + writeSharedSidebarWidth(width); + return width; + }); + }; + + handle.addEventListener('pointermove', handleMove); + handle.addEventListener('pointerup', handleEnd); + handle.addEventListener('pointercancel', handleEnd); + }, []); + + const toggleCollapsed = useCallback( + (file: ChangedFile, isCollapsed: boolean, reviewKey: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (isCollapsed) { + next.delete(reviewKey); + } else { + next.add(reviewKey); + } + return next; + }); + setExpandedGenerated((current) => { + const next = new Set(current); + if (isCollapsed && isGeneratedWalkthroughFile(file)) { + next.add(reviewKey); + } else { + next.delete(reviewKey); + } + return next; + }); + bumpItemVersion(reviewKey); + }, + [bumpItemVersion], + ); + const toggleViewed = useCallback( + (_file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => { + setViewed((current) => updateReviewIdentityViewed(current, reviewIdentity, isViewed)); + setCollapsed((current) => updateReviewIdentityCollapsed(current, reviewIdentity, isViewed)); + if (!isViewed) { + setExpandedGenerated((current) => { + const next = new Set(current); + next.delete(reviewIdentity.key); + return next; + }); + } + bumpItemVersion(reviewIdentity.key); }, - [setSelectedPath], + [bumpItemVersion], ); + const activateTreePath = useCallback((path: string) => { + setSelectedPath(path); + setTreeScrollTarget((current) => ({ + behavior: 'smooth', + path, + request: (current?.request ?? 0) + 1, + })); + }, []); const updateSelectedPathFromScroll = useCallback( (viewer: CodeViewInstance) => { - const nextPath = getSelectedPathFromScroll( - viewer, - visibleFiles, - snapshot.preferences.showWhitespace, - ); + if (visibleFiles.length === 0) { + return; + } + + const activationTop = viewer.getScrollTop() + DEFAULT_PADDING; + let nextPath = visibleFiles[0]?.path ?? null; + let nextDistance = Number.NEGATIVE_INFINITY; + + for (const file of visibleFiles) { + const section = getFirstVisibleSection(file, snapshot.preferences.showWhitespace); + const itemTop = section ? viewer.getTopForItem(getItemId(section)) : undefined; + if (itemTop == null) { + continue; + } + + const distance = itemTop - activationTop; + if (distance <= 0 && distance > nextDistance) { + nextDistance = distance; + nextPath = file.path; + } + } if (nextPath) { setSelectedPath((current) => (current === nextPath ? current : nextPath)); } }, - [setSelectedPath, snapshot.preferences.showWhitespace, visibleFiles], + [snapshot.preferences.showWhitespace, visibleFiles], ); const diffLineHeight = getCodeFontLineHeight( @@ -775,6 +2988,7 @@ export function ReviewSurface({ gitIdentity, hunkNavigation: null, initialMarkdownPreviewSectionIds, + isPullRequest: snapshot.repository.source.type === 'pull-request', isReadOnly: !canComment, itemVersionByKey, keymap, @@ -796,7 +3010,6 @@ export function ReviewSurface({ searchQuery: '', showWhitespace: snapshot.preferences.showWhitespace, source: snapshot.repository.source, - supportsReviewCommentActions: submitReviewComment != null, theme: snapshot.preferences.theme, viewed, wordWrap: snapshot.preferences.wordWrap, @@ -860,16 +3073,23 @@ export function ReviewSurface({ onActiveBlockChange: (blockId: string) => void, ) => { return ( - +
+ +
); }; @@ -885,6 +3105,36 @@ export function ReviewSurface({ ? (externalUrl ?? snapshot.repository.source.url) : null; const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; + const gitLabProjectUrl = externalUrl?.split('/-/merge_requests/')[0]; + const versionCompareFrom = + versions.find((version) => version.id === versionCompareFromId) ?? versionCompare?.from; + const versionCompareTo = + versions.find((version) => version.id === versionCompareToId) ?? versionCompare?.to; + const diffScopeSummary = versionCompareActive ? ( + versionCompareFrom && versionCompareTo ? ( + <> + + {' → '} + + + ) : ( + Choose versions + ) + ) : null; + const selectVersionComparisonScope = () => { + if (versionCompareActive) { + return; + } + setActiveCommitSha(null); + setCommitDiffError(null); + setSelectedTreeCommitShas(new Set()); + setTreeCommitDiffError(null); + setVersionSectionExpanded(true); + interactive?.onOpenVersionCompare?.(); + }; const walkthroughStatus = walkthroughRequestPending && interactive?.walkthroughStatus !== 'ready' ? 'generating' @@ -901,18 +3151,73 @@ export function ReviewSurface({ previousWalkthroughStatusRef.current = walkthroughStatus; }, [walkthroughStatus]); const walkthroughReady = !interactive || walkthroughStatus === 'ready'; + const versionWalkthroughFilesMissing = + walkthroughReady && + Boolean(versionCompare) && + versionWalkthroughStructure === 'commit-by-commit' && + walkthroughFiles.length === 0; const walkthroughFailed = walkthroughStatus === 'failed'; + const walkthroughIdle = walkthroughStatus === 'idle'; + const walkthroughStructurePhrase = versionCompareActive + ? versionWalkthroughStructure === 'commit-by-commit' + ? 'commit-by-commit version' + : 'whole-diff version' + : interactive?.reviewStrategy?.mode === 'commit-by-commit' + ? 'commit-by-commit' + : 'whole-diff'; + const computingVersionChanges = Boolean(versionCompareLoading); + const walkthroughBusy = walkthroughRequestPending || walkthroughStatus === 'generating'; + // Non-forced in-flight requests are either a cache lookup or the brief handoff before + // the server flips to generating. Forced rebuilds / active generation use generate copy. + const loadingWalkthroughLookup = + walkthroughBusy && !walkthroughRequestForce && walkthroughStatus !== 'generating'; + const generatingWalkthrough = + walkthroughBusy && (walkthroughRequestForce || walkthroughStatus === 'generating'); const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' - : 'Generating walkthrough…'; + : computingVersionChanges + ? 'Computing version changes…' + : walkthroughIdle && !walkthroughBusy + ? 'Walkthrough not generated' + : loadingWalkthroughLookup + ? `Loading ${walkthroughStructurePhrase} walkthrough…` + : `Generating ${walkthroughStructurePhrase} walkthrough…`; const walkthroughStatusDescription = walkthroughFailed ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') - : null; + : computingVersionChanges + ? 'Comparing the selected versions and preparing the review surface.' + : walkthroughIdle && !walkthroughBusy + ? versionCompareActive + ? 'Choose a walkthrough structure, then generate it for this version comparison.' + : 'Generate a walkthrough to review these changes.' + : (interactive?.walkthroughError ?? + (loadingWalkthroughLookup + ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough for this scope.` + : `Building the ${walkthroughStructurePhrase} walkthrough.`)); + const walkthroughProgressLabel = computingVersionChanges + ? 'Computing version changes…' + : loadingWalkthroughLookup + ? `Loading ${walkthroughStructurePhrase} walkthrough…` + : generatingWalkthrough + ? `Generating ${walkthroughStructurePhrase} walkthrough…` + : undefined; const shellTheme = snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; - const requestWalkthrough = () => { - startWalkthroughGeneration(); + const requestWalkthrough = (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + startWalkthroughGeneration(options); }; + const alternateReviewStructure = + interactive?.reviewStrategy?.mode === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; + const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); const reviewModes = [ { icon: , @@ -1056,43 +3361,649 @@ export function ReviewSurface({ value={fileSearchQuery} /> + {interactive && sidebarMode !== 'comments' ? ( +
+
+ {versionCompareActive ? ( + + ) : ( + + Diff scope + + )} +
+ + +
+
+ {versionSectionExpanded && versionCompareActive ? ( +
+ {versionHistoryLoading ? ( +
+ + Loading version history… +
+ ) : versions.length >= 2 && interactive?.onVersionCompareRangeChange ? ( +
+ + interactive.onVersionCompareRangeChange?.( + fromId, + versionCompareToId ?? versions[0]?.id ?? '', + ) + } + otherId={versionCompareToId} + value={versionCompareFromId ?? versions[1]?.id ?? versions[0]?.id ?? ''} + versions={versions} + /> + + interactive.onVersionCompareRangeChange?.( + versionCompareFromId ?? versions[1]?.id ?? '', + toId, + ) + } + otherId={versionCompareFromId} + value={versionCompareToId ?? versions[0]?.id ?? ''} + versions={versions} + /> +
+ ) : null} + {versionCompare?.analysis.baseMovement?.changed && + versionCompare.analysis.baseMovement ? ( +
+
+ Base changed{' '} + {' '} + →{' '} + +
+ {versionCompare.analysis.baseMovement.diffStat ? ( +
+ {formatBaseMovementCommitCount(versionCompare.analysis.baseMovement)} + {' · '} + {versionCompare.analysis.baseMovement.diffStat.filesChanged} files + {' · '} + + +{versionCompare.analysis.baseMovement.diffStat.additions} + {' '} + + −{versionCompare.analysis.baseMovement.diffStat.deletions} + + {' · '} + {formatBaseMovementRelationship( + versionCompare.analysis.baseMovement.relationship, + )} + {formatSignedBaseInterval( + versionCompare.analysis.baseMovement.commitTimestampDeltaMs, + ) + ? ` · ${formatSignedBaseInterval(versionCompare.analysis.baseMovement.commitTimestampDeltaMs)}` + : ''} +
+ ) : ( +
Details unavailable
+ )} + {(versionCompare.analysis.baseMovement.commits?.length ?? 0) > 0 ? ( +
+ + {versionCompare.analysis.baseMovement.relationship === 'backward' + ? 'Show previous base commits' + : 'Show new base commits'}{' '} + ({formatBaseMovementCommitCount(versionCompare.analysis.baseMovement)}) + +
+ {(versionCompare.analysis.baseMovement.commits ?? []).map((commit) => { + const isBackward = + versionCompare.analysis.baseMovement?.relationship === 'backward'; + return ( +
+ + {isBackward ? '−' : '+'} + + + {commit.subject} +
+ ); + })} +
+ {versionCompare.analysis.baseMovement.truncated ? ( + Commit list may be truncated by GitLab compare limits. + ) : null} +
+ ) : null} + {versionCompare.analysis.baseMovement.warning ? ( + {versionCompare.analysis.baseMovement.warning} + ) : null} + + Base branch changes are context only and are excluded from this review. + +
+ ) : null} + {versionCompareLoading ? ( +
+ + Computing changes between versions… +
+ ) : null} + {versionCompareError ? ( +
{versionCompareError}
+ ) : null} + {versionCommitEvolutionLoading ? ( +
+ + Analyzing commit evolution… +
+ ) : null} + {versionCommitEvolutionError ? ( +
+ Commit evolution could not be analyzed + {versionCommitEvolutionError} +
+ ) : null} + {versionCommitEvolution ? ( +
+ Commit stack +
+ {versionCommitEvolution.warnings?.map((warning) => ( +
+ {warning} +
+ ))} + {versionCommitEvolution.units.map((unit) => { + const commit = evolutionUnitCommit(unit); + if (!commit) { + return null; + } + const isUnchanged = + unit.kind === 'retained' || + unit.kind === 'rewritten-same-patch' || + unit.kind === 'absorbed-into-base'; + const symbol = + unit.kind === 'introduced' + ? '+' + : unit.kind === 'removed' + ? '−' + : unit.kind === 'revised' + ? '~' + : unit.kind === 'absorbed-into-base' + ? '↳' + : unit.kind === 'ambiguous' + ? '?' + : '·'; + const kindClass = + unit.kind === 'absorbed-into-base' + ? 'absorbed-into-base' + : isUnchanged + ? 'unchanged' + : unit.kind; + const walkthroughChapter = navigation.walkthroughView?.chapters.find( + (chapter) => chapter.commit?.sha === unit.id, + ); + const walkthroughStopIndex = walkthroughChapter?.stops[0]?.index; + const canNavigate = walkthroughStopIndex != null; + const rebaseDrivers = evolutionUnitRebaseDrivers(unit); + const driverTitle = + rebaseDrivers.length > 0 + ? `Likely rebase drivers: ${rebaseDrivers + .map((driver) => `${driver.shortSha} ${driver.subject}`) + .join('; ')}` + : null; + return ( +
+ + {rebaseDrivers.length > 0 ? ( +
+ + Rebase pressure + + {rebaseDrivers.map((driver) => ( +
+ + ~ + + + {driver.subject} +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + {selectedVersionUnits.length > 0 ? ( +
+ + Viewing {selectedVersionUnits.length}{' '} + {selectedVersionUnits.length === 1 ? 'commit change' : 'commit changes'} + + +
+ ) : null} + {versionCompare && !versionCompare.analysis.summary.empty ? ( +
+ Walkthrough structure + + + + {versionCommitEvolution?.recommendation.rationale ?? + 'Whole diff is available while commit evolution loads.'} + + +
+ ) : versionCompare && versionCommitEvolution?.summary.reviewable ? ( +
+ The final patch is equivalent, but the commit stack changed. +
+ ) : versionCompare ? ( +
+ These versions have no intentional MR changes. +
+ ) : null} +
+ ) : null} +
+ ) : null} {sidebarMode === 'tree' ? ( - + <> + {versionCompareActive ? ( + {}} + onToggleVersionUnit={toggleVersionUnit} + selectedVersionUnitIds={selectedVersionUnitIds} + versionCommitEvolution={versionCommitEvolution} + versionUnitError={versionUnitError} + versionUnitLoading={versionUnitLoading} + /> + ) : null} + {!versionCompareActive && commits.length > 0 ? ( + { + setSelectedTreeCommitShas(new Set()); + setTreeCommitDiffError(null); + setSelectedPath(snapshot.files[0]?.path ?? null); + }} + onSelectCommit={(sha) => { + if (!sha) { + return; + } + setSelectedTreeCommitShas((current) => { + const next = new Set(current); + if (next.has(sha)) { + next.delete(sha); + } else { + next.add(sha); + } + return next; + }); + setTreeCommitDiffError(null); + }} + selectedCommitShas={selectedTreeCommitShas} + /> + ) : null} + {treeCommitDiffLoading && selectedTreeCommitShas.size > 0 ? ( +
Loading selected commit changes…
+ ) : treeCommitDiffError ? ( +
{treeCommitDiffError}
+ ) : ( + + )} + + ) : sidebarMode === 'commits' ? ( +
+ {interactive?.reviewStrategy ? ( +
+ {interactive.reviewStrategy.mode === 'commit-by-commit' + ? 'Structured by commits' + : `Whole MR (${interactive.reviewStrategy.reason})`} +
+ ) : null} + {commits.map((commit) => { + const selected = commit.sha === activeCommitSha; + return ( + + ); + })} +
) : sidebarMode === 'comments' ? ( - + <> +
+ +
+ + + {overviewComments.length > 0 ? ( + + ) : !commentsLoading && aiReviews.length === 0 ? ( +
No overview comments.
+ ) : null} +
+ + + interactive?.onOpenVersionCompare?.({ commentId }) + } + /> + + ) : walkthroughReady ? ( - + <> + {!versionCompareActive && interactive?.reviewStrategy ? ( +
+ + {interactive.reviewStrategy.mode === 'commit-by-commit' + ? 'Structured by commits' + : `Whole MR (${interactive.reviewStrategy.reason})`} + + +
+ ) : null} + 0 ? versionCompareChangedPaths : undefined + } + commitEvolutionKinds={ + versionCommitEvolution + ? new Map(versionCommitEvolution.units.map((unit) => [unit.id, unit.kind])) + : undefined + } + files={visibleFiles} + navigation={navigation} + onRegenerateWalkthrough={ + versionCompareActive && versionCompareWalkthroughOptions + ? () => + requestWalkthrough({ + ...versionCompareWalkthroughOptions, + force: true, + }) + : interactive?.reviewStrategy + ? () => + requestWalkthrough({ + force: true, + reviewStructure: + interactive.reviewStrategy?.mode === 'commit-by-commit' + ? 'commit-by-commit' + : 'whole-diff', + }) + : () => requestWalkthrough({ force: true }) + } + showWhitespace={snapshot.preferences.showWhitespace} + walkthrough={sharedWalkthrough} + /> + ) : (
- {walkthroughFailed ? ( - {walkthroughStatusTitle} + {walkthroughFailed || + (walkthroughIdle && !walkthroughBusy && !computingVersionChanges) ? ( + <> + {walkthroughStatusTitle} + {walkthroughStatusDescription ? ( + {walkthroughStatusDescription} + ) : null} + ) : ( )} - {walkthroughStatusDescription ? {walkthroughStatusDescription} : null}
)} @@ -1110,8 +4021,11 @@ export function ReviewSurface({
{sidebarMode === 'comments' ? ( + ) : sidebarMode === 'commits' ? ( + commitDiffLoading && !activeCommitFiles ? ( +
Loading commit diff…
+ ) : commitDiffError ? ( +
+
+ Unable to load commit +

{commitDiffError}

+
+
+ ) : visibleFiles.length === 0 ? ( +
+
+ {activeCommit ? activeCommit.subject : 'Select a commit'} + + {activeCommit + ? 'This commit has no loadable text diffs.' + : 'Choose a commit from the sidebar.'} + +
+ +
+
+
+ ) : ( + <> +
+ {activeCommit ? activeCommit.subject : 'Commit'} + + {activeCommit ? ( + + ) : null} + + +
+ + Comments are on the full MR diff. + + + } + walkthroughNotes={emptyWalkthroughNotes} + /> + + ) ) : sidebarMode === 'tree' ? ( - visibleFiles.length === 0 ? ( + computingVersionChanges ? ( +
+ +
+ ) : versionCompareActive && + selectedVersionUnitIds.size > 0 && + versionUnitLoading && + selectedVersionUnitFiles.length === 0 ? ( +
Loading selected commit changes…
+ ) : versionCompareActive && + selectedVersionUnitIds.size > 0 && + versionUnitError && + selectedVersionUnitFiles.length === 0 ? ( +
+
+ Unable to load selected commit changes +

{versionUnitError}

+
+
+ ) : treeCommitDiffLoading && + selectedTreeCommitShas.size > 0 && + selectedTreeCommitFiles.length === 0 ? ( +
Loading selected commit changes…
+ ) : treeCommitDiffError ? ( +
+
+ Unable to load selected commit changes +

{treeCommitDiffError}

+
+
+ ) : visibleFiles.length === 0 ? (
- No matching files - {fileSearchQuery} + {fileSearchQuery ? 'No matching files' : 'No files in this diff'} + {fileSearchQuery ? {fileSearchQuery} : null}
) : ( @@ -1147,6 +4171,11 @@ export function ReviewSurface({ allowViewedToggle files={visibleFiles} forceExpandedPaths={emptyPaths} + key={ + versionCompareActive + ? `version:${selectedVersionUnits.map((unit) => unit.id).join(',') || 'all'}` + : `commits:${[...selectedTreeCommitShas].join(',') || 'all'}` + } onSelectPathFromScroll={updateSelectedPathFromScroll} scrollTarget={treeScrollTarget} selectedPath={visibleSelectedPath} @@ -1156,40 +4185,323 @@ export function ReviewSurface({ walkthroughNotes={emptyWalkthroughNotes} /> ) + ) : versionWalkthroughFilesMissing ? ( +
+
+ Commit walkthrough code is unavailable +

Regenerate this walkthrough to restore its authored commit-unit diff.

+ {versionCompareWalkthroughOptions ? ( + + ) : null} +
+
+ ) : walkthroughReady && + walkthroughCommitSha && + !commitFilesBySha[walkthroughCommitSha] && + commitDiffLoading ? ( +
Loading commit walkthrough code…
+ ) : walkthroughReady && + walkthroughCommitSha && + !commitFilesBySha[walkthroughCommitSha] && + commitDiffError ? ( +
+
+ Unable to load commit walkthrough code +

{commitDiffError}

+
+
) : walkthroughReady ? ( - + <> + {versionCompare && + versionWalkthroughStructure === 'whole-diff' && + versionCompare.analysis.baseMovement?.changed && + versionCompare.analysis.baseMovement ? ( +
+ Target base changed between versions + {versionCommitEvolution?.summary.absorbedIntoBase ? ( + + {versionCommitEvolution.summary.absorbedIntoBase}{' '} + {versionCommitEvolution.summary.absorbedIntoBase === 1 + ? 'earlier MR commit is' + : 'earlier MR commits are'}{' '} + now supplied by the target base. + + ) : ( + + Base movement is context only; this walkthrough covers the remaining MR changes. + + )} + {(versionCompare.analysis.baseMovement.commits?.length ?? 0) > 0 ? ( +
+ Show base commits +
+ {(versionCompare.analysis.baseMovement.commits ?? []).map((commit) => ( +
+ + {commit.subject} +
+ ))} +
+
+ ) : null} +
+ ) : null} + 0 ? versionCompareChangedPaths : undefined + } + files={walkthroughFiles} + navigation={navigation} + onActiveReviewTargetChange={noop} + onCommit={disabledCommit} + onUpdateCommitMessage={disabledCommitMessage} + renderDiffBlocks={renderWalkthroughDiffBlocks} + showWhitespace={snapshot.preferences.showWhitespace} + walkthrough={sharedWalkthrough} + /> + + ) : computingVersionChanges || walkthroughBusy ? ( +
+ +
) : walkthroughFailed ? (
{walkthroughStatusTitle}

{walkthroughStatusDescription}

- + {!versionCompareActive && interactive?.reviewStrategy ? ( + + ) : null}
- ) : ( -
- + ) : walkthroughIdle ? ( +
+
+ {walkthroughStatusTitle} +

{walkthroughStatusDescription}

+
+ +
+
- )} + ) : null}
); } + +export function SharedWalkthroughApp({ + commenting, + gitIdentity, + settingsBar, + snapshot, +}: { + commenting?: SharedWalkthroughCommenting; + gitIdentity?: GitIdentity | null; + settingsBar?: ReactNode; + snapshot: SharedWalkthroughSnapshot; +}) { + return ( + + ); +} + +export function MergeRequestReviewApp({ + aiReviews, + commentsError, + commentsLoading, + commits, + externalUrl, + gitIdentity, + initialMode, + onCancelAutoMerge, + onClosePullRequest, + onExitVersionCompare, + onGenerateWalkthrough, + onHome, + onLoadCommitDiff, + onLoadVersionCommitDiff, + onMergePullRequest, + onModeChange, + onOpenVersionCompare, + onResolveDiscussion, + onSubmitComment, + onSubmitGeneralComment, + onSubmitReview, + onUpdateComment, + onUpdateDescription, + onUpdateGeneralComment, + onUpdateTitle, + onUploadDescriptionAsset, + onVersionCompareRangeChange, + onVersionWalkthroughStructureChange, + preferences, + reviewStrategy, + selectedCommitSha, + settingsBar, + sourceDescriptionFooterAside, + state, + title, + versionCommitEvolution, + versionCommitEvolutionError, + versionCommitEvolutionLoading, + versionCompare, + versionCompareEnabled, + versionCompareError, + versionCompareFromId, + versionCompareLoading, + versionCompareToId, + versionHistoryLoading, + versions, + versionWalkthroughStructure, + walkthrough, + walkthroughError, + walkthroughStatus, +}: MergeRequestReviewAppProps) { + const placeholderWalkthrough = useMemo( + () => ({ + agent: 'codex', + chapters: [], + focus: 'Generate a walkthrough to review this merge request in narrative order.', + generatedAt: new Date(state.generatedAt).toISOString(), + kind: 'narrative', + repo: { + branch: state.branch, + root: state.root, + }, + source: state.source, + support: [], + title, + version: 4, + }), + [state.branch, state.generatedAt, state.root, state.source, title], + ); + const resolvedPreferences = useMemo( + () => ({ + ...defaultSharedPreferences, + ...preferences, + }), + [preferences], + ); + const snapshot = useMemo( + () => ({ + branch: state.branch, + codeQualityFindings: state.codeQualityFindings, + codiffVersion: 'web', + exportedAt: new Date(state.generatedAt).toISOString(), + files: state.files, + kind: 'codiff-walkthrough-share', + preferences: resolvedPreferences, + repository: { + generalComments: state.generalComments, + root: state.root, + source: state.source, + title, + }, + reviewComments: state.reviewComments, + version: 1, + walkthrough: walkthrough ?? placeholderWalkthrough, + }), + [placeholderWalkthrough, resolvedPreferences, state, title, walkthrough], + ); + + return ( + + ); +} diff --git a/core/__tests__/App.test.tsx b/core/__tests__/App.test.tsx index 7099236a..bd7e72d0 100644 --- a/core/__tests__/App.test.tsx +++ b/core/__tests__/App.test.tsx @@ -8,6 +8,7 @@ import { getSectionForFileDiff, getTotalDiffLineCount, getVisibleDiffSections, + formatTreeLineCount, fileHasVisibleDiff, loadSectionContents, shouldLoadDiffSectionContents, @@ -397,6 +398,16 @@ test('diff line counts include additions and deletions across sections', () => { }); }); +test('tree line counts keep additions and deletions separate', () => { + expect( + formatTreeLineCount({ + additions: 1234, + countable: true, + deletions: 56, + }), + ).toBe('+1.2k -56'); +}); + test('diff line counts omit binary summary rows', () => { const file = { fingerprint: 'binary-counts', diff --git a/core/app/components/CommitRefTooltip.tsx b/core/app/components/CommitRefTooltip.tsx new file mode 100644 index 00000000..f4c2b98c --- /dev/null +++ b/core/app/components/CommitRefTooltip.tsx @@ -0,0 +1,83 @@ +import { Tooltip } from '@base-ui/react/tooltip'; +import { ExternalLink } from 'lucide-react'; + +export type CommitRefSummary = { + additions?: number; + authoredAt?: number | string | null; + authorName?: string; + deletions?: number; + sha: string; + shortSha: string; + subject?: string; + webUrl?: string; +}; + +export function CommitRefTooltip({ + className, + commit, + focusable = true, + linkTrigger = true, +}: { + className?: string; + commit: CommitRefSummary; + focusable?: boolean; + linkTrigger?: boolean; +}) { + const triggerClassName = ['git-commit-ref-trigger', className].filter(Boolean).join(' '); + const trigger = + linkTrigger && commit.webUrl ? ( + + ) : ( + + ); + const authoredAt = commit.authoredAt ? new Date(commit.authoredAt) : null; + const authoredLabel = + authoredAt && Number.isFinite(authoredAt.getTime()) ? authoredAt.toLocaleString() : null; + + return ( + + + {commit.shortSha} + + + + + {commit.subject || 'Commit'} + {commit.sha} + {commit.authorName || authoredLabel ? ( + + {[commit.authorName, authoredLabel].filter(Boolean).join(' · ')} + + ) : null} + {commit.additions != null || commit.deletions != null ? ( + + {commit.additions != null ? ( + +{commit.additions} + ) : null} + {commit.deletions != null ? ( + −{commit.deletions} + ) : null} + + ) : null} + {commit.webUrl ? ( + + View commit in GitLab + + + ) : null} + + + + + ); +} diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx new file mode 100644 index 00000000..b0244f8d --- /dev/null +++ b/core/app/components/CommitScopePanel.tsx @@ -0,0 +1,157 @@ +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { evolutionUnitCommit } from '../../lib/review-history.ts'; +import type { + MergeRequestCommitListEntry, + MergeRequestVersionCommitEvolution, + MergeRequestVersionCommitEvolutionUnit, +} from '../../SharedWalkthroughApp.tsx'; +import { CommitRefTooltip } from './CommitRefTooltip.tsx'; + +export type CommitScopePanelProps = { + commits: ReadonlyArray; + mode: 'merge-request' | 'version-compare'; + onClear: () => void; + onSelectCommit: (sha: string | null) => void; + onToggleVersionUnit?: (unit: MergeRequestVersionCommitEvolutionUnit) => void; + selectedCommitShas?: ReadonlySet; + selectedVersionUnitIds?: ReadonlySet; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionUnitError?: string | null; + versionUnitLoading?: boolean; +}; + +export function CommitScopePanel({ + commits, + mode, + onClear, + onSelectCommit, + onToggleVersionUnit, + selectedCommitShas = new Set(), + selectedVersionUnitIds = new Set(), + versionCommitEvolution, + versionUnitError, + versionUnitLoading = false, +}: CommitScopePanelProps) { + const [expanded, setExpanded] = useState(true); + const selectedUnits = + versionCommitEvolution?.units.filter((unit) => selectedVersionUnitIds.has(unit.id)) ?? []; + const summary = + mode === 'merge-request' + ? selectedCommitShas.size > 0 + ? `${selectedCommitShas.size} selected commit${selectedCommitShas.size === 1 ? '' : 's'}` + : 'All merge request changes' + : selectedUnits.length > 0 + ? `${selectedUnits.length} selected commit${selectedUnits.length === 1 ? '' : 's'}` + : 'All version changes'; + + return ( +
+
+ + +
+ {expanded ? ( +
+ {mode === 'merge-request' ? ( + <> + {commits.map((commit) => ( + + ))} + + ) : ( + versionCommitEvolution?.units + .filter((unit) => unit.reviewable) + .map((unit) => { + const commit = evolutionUnitCommit(unit); + if (!commit || !onToggleVersionUnit) { + return null; + } + return ( + + ); + }) + )} + {versionUnitLoading ? ( +
Loading selected commit changes…
+ ) : null} + {versionUnitError ? ( +
{versionUnitError}
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 0bc31b41..79d16244 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -113,7 +113,6 @@ import type { ReviewSource, } from '../../types.ts'; import { Avatar } from './Avatar.tsx'; -import { Button } from './Button.tsx'; import { RepositoryMarkdownEditor, type MarkdownDocumentEditorHandle, @@ -143,16 +142,6 @@ const preloadMarkdownEditor = () => { }; const MarkdownEditor = lazy(loadMarkdownEditor); -const isEditableWorkingTreeSection = ( - sourceType: ReviewSource['type'], - file: ChangedFile, - section: DiffSection, -) => - (sourceType === 'working-tree' || sourceType === 'branch-working-tree') && - file.status !== 'deleted' && - file.sections.at(-1)?.id === section.id && - (section.kind === 'staged' || section.kind === 'unstaged'); - function CopyFilePathButton({ path }: { path: string }) { const [copied, markCopied] = useCopiedState(1600); @@ -285,7 +274,7 @@ function CodeViewHeader({
{statusLabel[file.status]}
{canCreateFileComment ? ( - + ) : null} {canRenderMarkdown ? ( - + ) : null} {canLoadSection && !readOnly ? ( + ) : null} {!readOnly || allowViewedToggle ? ( + + + {mode === 'history' ? ( ) : mode === 'walkthrough' && narrativeWalkthrough ? ( ) : ( - +
+ +
)} {showFooter ? (
@@ -193,14 +408,14 @@ export function Sidebar({ ) : null} {showCommitButton ? ( - + ) : null}
) : null} @@ -208,6 +423,96 @@ export function Sidebar({ ); } +const escapeCSSString = (value: string) => + value + .replaceAll('\\', String.raw`\\`) + .replaceAll('\n', String.raw`\a `) + .replaceAll('\r', String.raw`\d `) + .replaceAll('\f', String.raw`\c `) + .replaceAll('"', String.raw`\"`); + +const getReloadDeltaGitStatusCSS = (paths: ReadonlySet) => + [...paths] + .map( + (path) => ` + [data-item-path="${escapeCSSString(path)}"][data-item-git-status] > [data-item-section='git'] { + color: var(--sidebar-ref); + } + `, + ) + .join('\n'); + +const getViewedRowCSS = (files: ReadonlyArray, viewed: Record) => + getViewedRowCSSFromSelectors( + files + .filter((file) => viewed[file.path] === file.fingerprint) + .map((file) => `[data-item-path="${escapeCSSString(file.path)}"]`), + ); + +const getViewedRowCSSFromSelectors = (selectors: ReadonlyArray) => { + if (selectors.length === 0) { + return ''; + } + + const rowContent = selectors + .flatMap((selector) => [ + `${selector} > [data-item-section='icon']`, + `${selector} > [data-item-section='icon'] > :where(:not([data-icon-name='file-tree-icon-chevron']))`, + `${selector} > [data-item-section='content']`, + `${selector} > [data-item-section='decoration']`, + `${selector} > [data-item-section='git']`, + ]) + .join(',\n'); + + return ` + ${rowContent} { + color: var(--muted); + } + `; +}; + +const reloadDeltaGitStatusStyleAttribute = 'data-codiff-reload-delta-git-status'; +const viewedRowStyleAttribute = 'data-codiff-viewed-rows'; + +const useTreeShadowStyle = ( + treeHostRef: RefObject, + styleAttribute: string, + css: string, +) => { + useEffect(() => { + // Tree unsafeCSS is constructor-time; keep dynamic row styling in a shadow style tag. + if (syncTreeShadowStyle(treeHostRef.current, styleAttribute, css)) { + return; + } + + const frame = window.requestAnimationFrame(() => { + syncTreeShadowStyle(treeHostRef.current, styleAttribute, css); + }); + return () => window.cancelAnimationFrame(frame); + }, [css, styleAttribute, treeHostRef]); +}; + +const syncTreeShadowStyle = (treeHost: HTMLElement | null, styleAttribute: string, css: string) => { + const shadowRoot = treeHost?.querySelector('file-tree-container')?.shadowRoot; + if (!shadowRoot) { + return false; + } + + const existingStyle = shadowRoot.querySelector(`style[${styleAttribute}]`); + if (css.length === 0) { + existingStyle?.remove(); + return true; + } + + const style = existingStyle ?? document.createElement('style'); + style.setAttribute(styleAttribute, ''); + style.textContent = css; + if (!existingStyle) { + shadowRoot.append(style); + } + return true; +}; + const shortDate = (timestamp: number) => { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) { @@ -254,6 +559,20 @@ function HistorySidebar({ searchQuery: string; }) { const currentSourceKey = getSourceKey(currentSource); + const getGitLabCommitUrl = (ref: string) => { + if ( + currentSource.type !== 'pull-request' || + currentSource.provider !== 'gitlab' || + !currentSource.projectPath + ) { + return undefined; + } + try { + return `${new URL(currentSource.url).origin}/${currentSource.projectPath}/-/commit/${encodeURIComponent(ref)}`; + } catch { + return undefined; + } + }; const normalizedQuery = searchQuery.trim().toLowerCase(); const listRef = useRef(null); const rows = useMemo(() => { @@ -328,28 +647,6 @@ function HistorySidebar({ subject: 'Uncommitted changes', } : null, - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: getSourceKey({ - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - }), - kind: 'entry' as const, - ref: 'branch+', - source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - } satisfies ReviewSource, - subject: `All changes vs ${branchSource.ref}`, - } - : null, !normalizedQuery ? { author: null, @@ -359,7 +656,7 @@ function HistorySidebar({ kind: 'entry' as const, ref: 'branch', source: branchSource satisfies ReviewSource, - subject: `Committed only vs ${branchSource.ref}`, + subject: `Branch diff vs ${branchSource.ref}`, } : null, localRows.length > 0 @@ -424,10 +721,21 @@ function HistorySidebar({ > {row.source.type === 'commit' - ? getShortRef(row.source.ref) - : row.source.type === 'pull-request' || - row.source.type === 'branch-diff' || - row.source.type === 'branch-working-tree' + ? ( + + ) + : row.source.type === 'pull-request' || row.source.type === 'branch-diff' ? row.ref : 'local'} diff --git a/core/app/components/walkthrough/NarrativeSidebar.tsx b/core/app/components/walkthrough/NarrativeSidebar.tsx index 8630857f..83b50af0 100644 --- a/core/app/components/walkthrough/NarrativeSidebar.tsx +++ b/core/app/components/walkthrough/NarrativeSidebar.tsx @@ -1,7 +1,4 @@ -import { CheckIcon as Check } from '@phosphor-icons/react/Check'; -import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; +import { getAgentLabel } from '../../../lib/app-constants.ts'; import { renderInlineMarkdown } from '../../../lib/markdown.tsx'; import { buildCommitModel, @@ -13,9 +10,13 @@ import { type WalkthroughStopView, } from '../../../lib/narrative-walkthrough.ts'; import type { ChangedFile, NarrativeWalkthrough } from '../../../types.ts'; +import { CommitRefTooltip } from '../CommitRefTooltip.tsx'; +import { ArrowsClockwise, Check, GitBranch, Path, ShareNetwork } from './icons.tsx'; import { ChapterIcon } from './parts.tsx'; import type { NarrativeNavigation } from './useNarrativeNavigation.ts'; +const agentLabel = (agentId: NarrativeWalkthrough['agent']) => getAgentLabel(agentId); + function TocFileRows({ files, }: { @@ -88,11 +89,13 @@ function TocStop({ } function SupportingFilesStop({ + changedPaths, files, navigation, showWhitespace, walkthroughView, }: { + changedPaths?: ReadonlySet; files: ReadonlyArray; navigation: NarrativeNavigation; showWhitespace: boolean; @@ -102,7 +105,7 @@ function SupportingFilesStop({ files, walkthroughView, showWhitespace, - ); + ).filter((file) => !changedPaths?.has(file.path)); if (walkthroughView.support.length === 0 && uncoveredFiles.length === 0) { return null; } @@ -147,18 +150,111 @@ function SupportingFilesStop({ ); } +function ChangedFilesStop({ + changedPaths, + files, + navigation, + showWhitespace, + walkthroughView, +}: { + changedPaths?: ReadonlySet; + files: ReadonlyArray; + navigation: NarrativeNavigation; + showWhitespace: boolean; + walkthroughView: WalkthroughView; +}) { + const changedFiles = getUncoveredWalkthroughFileLineItems( + files, + walkthroughView, + showWhitespace, + ).filter((file) => changedPaths?.has(file.path)); + if (changedFiles.length === 0) { + return null; + } + const fileRows = formatWalkthroughFileLineRows(changedFiles); + return ( +
+
+ + + + Changed +
+
+ +
+
+ ); +} + +const commitEvolutionKindClass = (kind: string | undefined) => { + switch (kind) { + case 'added': + case 'introduced': + return 'evolution-added'; + case 'removed': + return 'evolution-removed'; + case 'likely-revised': + case 'revised': + return 'evolution-revised'; + case 'absorbed-into-base': + return 'evolution-absorbed'; + case 'ambiguous': + return 'evolution-ambiguous'; + default: + return 'evolution-unchanged'; + } +}; + +const commitEvolutionSymbol = (kind: string | undefined) => { + switch (kind) { + case 'added': + case 'introduced': + return '+'; + case 'removed': + return '−'; + case 'likely-revised': + case 'revised': + return '~'; + case 'absorbed-into-base': + return '↳'; + case 'ambiguous': + return '?'; + default: + return '·'; + } +}; + export function NarrativeSidebar({ allowCommit = true, + changedPaths, + commitEvolutionKinds, files, navigation, + onRegenerateWalkthrough, onShareWalkthrough, shareWalkthroughDisabled = false, showWhitespace, walkthrough, }: { allowCommit?: boolean; + changedPaths?: ReadonlySet; + commitEvolutionKinds?: ReadonlyMap; files: ReadonlyArray; navigation: NarrativeNavigation; + onRegenerateWalkthrough?: () => void; onShareWalkthrough?: () => void; shareWalkthroughDisabled?: boolean; showWhitespace: boolean; @@ -183,13 +279,75 @@ export function NarrativeSidebar({ return (
- Review focus +
+ Review focus + {onRegenerateWalkthrough ? ( + + ) : null} +

{renderInlineMarkdown(walkthrough.focus)}

{walkthroughView.chapters.map((chapter) => ( -
+
+ {chapter.commit ? ( +
`${driver.shortSha} ${driver.subject}`, + ), + chapter.commit.revisionCause, + ] + .filter(Boolean) + .join(' · ')} + > + {commitEvolutionKinds ? ( + + {commitEvolutionSymbol(commitEvolutionKinds.get(chapter.commit.sha))} + + ) : null} + + {chapter.commit.subject} + {(chapter.commit.rebaseDrivers?.length ?? 0) > 0 ? ( + `${driver.shortSha} ${driver.subject}`) + .join('; ')}`} + className="wt-commit-rebase-badge" + tabIndex={0} + > + Revised due to rebase + + ) : chapter.commit.revisionCause ? ( + + Revised due to rebase + + ) : null} +
+ ) : null}
@@ -210,6 +368,14 @@ export function NarrativeSidebar({
))} + ); } + +export { agentLabel }; diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index ac072a37..8568d8a7 100644 --- a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx +++ b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx @@ -1,11 +1,3 @@ -import { ArrowLeftIcon as ArrowLeft } from '@phosphor-icons/react/ArrowLeft'; -import { ArrowRightIcon as ArrowRight } from '@phosphor-icons/react/ArrowRight'; -import { CaretLeftIcon as CaretLeft } from '@phosphor-icons/react/CaretLeft'; -import { CaretRightIcon as CaretRight } from '@phosphor-icons/react/CaretRight'; -import { CheckIcon as Check } from '@phosphor-icons/react/Check'; -import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { ReviewIdentity } from '../../../lib/app-types.ts'; import type { ReviewScrollBehavior } from '../../../lib/app-types.ts'; @@ -24,6 +16,7 @@ import { type WalkthroughStopView, } from '../../../lib/narrative-walkthrough.ts'; import type { ChangedFile, NarrativeWalkthrough, WalkthroughHunkGroup } from '../../../types.ts'; +import { CommitRefTooltip } from '../CommitRefTooltip.tsx'; import type { ReviewDiffBlock } from '../ReviewCodeView.tsx'; import { CommitView, @@ -31,6 +24,17 @@ import { type CommitMessageHandler, type CommitOutputSubscriber, } from './CommitView.tsx'; +import { + ArrowLeft, + ArrowRight, + ArrowsClockwise, + CaretLeft, + CaretRight, + Check, + GitBranch, + Path, + ShareNetwork, +} from './icons.tsx'; import { ChapterIcon, ImportancePill, Narration } from './parts.tsx'; import type { NarrativeNavigation } from './useNarrativeNavigation.ts'; @@ -153,6 +157,29 @@ function StopHeader({ current, stop }: { current: boolean; stop: WalkthroughStop
+ {stop.commentReferences?.length ? ( +
+ {stop.commentReferences.map((comment) => ( +
+
+ {comment.url ? ( + Review comment by {comment.authorName} + ) : ( + Review comment by {comment.authorName} + )} + + {comment.status === 'resolved-by-change' + ? 'Addressed in this version' + : comment.status === 'outdated' + ? 'Code changed since comment' + : 'Related comment'} + +
+
{comment.body}
+
+ ))} +
+ ) : null}
); } @@ -169,6 +196,90 @@ function SupportHeader({ current }: { current: boolean }) { ); } +function CommitBoundary({ + commit, +}: { + commit: NonNullable; +}) { + const rebaseDrivers = commit.rebaseDrivers ?? []; + return ( +
+
+ Commit + + {commit.subject} +
+ {rebaseDrivers.length > 0 ? ( +
+ Revised due to rebase +
+ {rebaseDrivers.map((driver) => ( +
+ + {driver.subject} + {driver.overlappingPaths?.length ? ( + Overlaps {driver.overlappingPaths.join(', ')} + ) : null} +
+ ))} +
+
+ ) : commit.revisionCause ? ( +
+ Revised due to rebase + {commit.revisionCause} +
+ ) : null} +
+ ); +} + +function ChangedHeader({ + current, + onRegenerate, + regenerateDisabled = false, +}: { + current: boolean; + onRegenerate?: () => void; + regenerateDisabled?: boolean; +}) { + return ( +
+
+

Changed

+ {onRegenerate ? ( + + ) : null} +
+ +
+ ); +} + const createWalkthroughBlocks = ( files: ReadonlyArray, walkthroughView: WalkthroughView, @@ -178,36 +289,45 @@ const createWalkthroughBlocks = ( const firstBlockIdByStop: Array = []; const stopIndexByBlockId = new Map(); - for (const stop of walkthroughView.sequence) { - const focusedRuns = getFocusedRunDiffs(stop, files); - if (focusedRuns.length === 0) { - const blockId = `walkthrough:${stop.id}:missing`; - firstBlockIdByStop[stop.index] = blockId; - stopIndexByBlockId.set(blockId, stop.index); + for (const chapter of walkthroughView.chapters) { + if (chapter.commit) { blocks.push({ - header: , - headerSelected: stop.index === currentIndex, - id: blockId, + header: , + headerSelected: false, + id: `walkthrough:commit:${chapter.commit.sha}`, }); - continue; } + for (const stop of chapter.stops) { + const header = ; + const focusedRuns = getFocusedRunDiffs(stop, files); + if (focusedRuns.length === 0) { + const blockId = `walkthrough:${stop.id}:missing`; + firstBlockIdByStop[stop.index] = blockId; + stopIndexByBlockId.set(blockId, stop.index); + blocks.push({ + header, + headerSelected: stop.index === currentIndex, + id: blockId, + }); + continue; + } - firstBlockIdByStop[stop.index] = `walkthrough:${stop.id}:0`; + firstBlockIdByStop[stop.index] = `walkthrough:${stop.id}:0`; - focusedRuns.forEach(({ file, note, reviewIdentity }, runIndex) => { - const blockId = `walkthrough:${stop.id}:${runIndex}`; - stopIndexByBlockId.set(blockId, stop.index); - blocks.push({ - file, - header: - runIndex === 0 ? : null, - headerSelected: stop.index === currentIndex, - id: blockId, - itemIdPrefix: blockId, - note, - reviewIdentity, + focusedRuns.forEach(({ file, note, reviewIdentity }, runIndex) => { + const blockId = `walkthrough:${stop.id}:${runIndex}`; + stopIndexByBlockId.set(blockId, stop.index); + blocks.push({ + file, + header: runIndex === 0 ? header : null, + headerSelected: stop.index === currentIndex, + id: blockId, + itemIdPrefix: blockId, + note, + reviewIdentity, + }); }); - }); + } } return { blocks, firstBlockIdByStop, stopIndexByBlockId }; @@ -231,6 +351,9 @@ const createSupportBlocks = ( selected: boolean, walkthroughView: WalkthroughView, showWhitespace: boolean, + changedPaths?: ReadonlySet, + onRegenerateWalkthrough?: () => void, + regenerateDisabled?: boolean, ): ReadonlyArray => { const blocks: Array = []; for (const group of walkthroughView.supportByReason) { @@ -252,7 +375,13 @@ const createSupportBlocks = ( } const uncoveredFiles = getUncoveredWalkthroughFiles(files, walkthroughView, showWhitespace); - for (const file of uncoveredFiles) { + // Changes that arrived after the walkthrough was generated (e.g. via an + // in-place refresh) get their own "Changed" section; other uncovered hunks + // stay under "Support" as before. + const changedFiles = uncoveredFiles.filter((file) => changedPaths?.has(file.path)); + const uncoveredSupportFiles = uncoveredFiles.filter((file) => !changedPaths?.has(file.path)); + + for (const file of uncoveredSupportFiles) { const blockId = `walkthrough:uncovered:${file.path}`; const isFirstBlock = blocks.length === 0; blocks.push({ @@ -269,6 +398,29 @@ const createSupportBlocks = ( }); } + changedFiles.forEach((file, fileIndex) => { + const blockId = `walkthrough:changed:${file.path}`; + blocks.push({ + file, + header: + fileIndex === 0 ? ( + + ) : null, + headerSelected: selected, + id: blockId, + itemIdPrefix: blockId, + note: 'Changed after the walkthrough was generated.', + reviewIdentity: { + fingerprint: file.fingerprint, + key: blockId, + }, + }); + }); + return blocks; }; @@ -395,6 +547,17 @@ function Arc({
+ {chapter.commit ? ( + + ) : null} {chapter.title}
@@ -497,26 +660,32 @@ function Arc({ export function NarrativeWalkthroughView({ allowCommit = true, + changedPaths, files, navigation, onActiveReviewTargetChange, onCommit, onCommitOutput, + onRegenerateWalkthrough, onShareWalkthrough, onUpdateCommitMessage, + regenerateDisabled, renderDiffBlocks, shareWalkthroughDisabled, showWhitespace, walkthrough, }: { allowCommit?: boolean; + changedPaths?: ReadonlySet; files: ReadonlyArray; navigation: NarrativeNavigation; onActiveReviewTargetChange: (target: WalkthroughReviewTarget | null) => void; onCommit: CommitHandler; onCommitOutput?: CommitOutputSubscriber; + onRegenerateWalkthrough?: () => void; onShareWalkthrough?: () => void; onUpdateCommitMessage: CommitMessageHandler; + regenerateDisabled?: boolean; renderDiffBlocks: RenderWalkthroughDiffBlocks; shareWalkthroughDisabled?: boolean; showWhitespace: boolean; @@ -534,9 +703,25 @@ export function NarrativeWalkthroughView({ const supportBlocks = useMemo( () => walkthroughView - ? createSupportBlocks(files, navigation.mode === 'support', walkthroughView, showWhitespace) + ? createSupportBlocks( + files, + navigation.mode === 'support', + walkthroughView, + showWhitespace, + changedPaths, + onRegenerateWalkthrough, + regenerateDisabled, + ) : [], - [files, navigation.mode, showWhitespace, walkthroughView], + [ + changedPaths, + files, + navigation.mode, + onRegenerateWalkthrough, + regenerateDisabled, + showWhitespace, + walkthroughView, + ], ); const supportAvailable = supportBlocks.length > 0; const firstSupportBlockId = supportBlocks[0]?.id ?? null; diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index a116272b..03f042b2 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -16,10 +16,14 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; export function WalkthroughProgress({ + detail, + label: labelOverride, phase, responseLabelIndex, stageRevision, }: { + detail?: string | null; + label?: string; phase: WalkthroughProgressPhase | null; responseLabelIndex: number; stageRevision: number; @@ -40,21 +44,25 @@ export function WalkthroughProgress({ const elapsedSeconds = timerState.stageRevision === stageRevision ? timerState.elapsedSeconds : 0; const showTimer = elapsedSeconds >= TIMER_THRESHOLD_SECONDS; const label = - phase === 'agent-generation' + labelOverride ?? + (phase === 'agent-generation' ? 'Analyzing changes…' : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] - : 'Generating walkthrough…'; + : 'Generating walkthrough…'); return ( - {label} - + {label}{detail ? ` · ${detail}` : ''} + + + ); } diff --git a/core/app/components/walkthrough/icons.tsx b/core/app/components/walkthrough/icons.tsx new file mode 100644 index 00000000..9de9e249 --- /dev/null +++ b/core/app/components/walkthrough/icons.tsx @@ -0,0 +1,21 @@ +import { ArrowLeftIcon as ArrowLeft } from '@phosphor-icons/react/ArrowLeft'; +import { ArrowRightIcon as ArrowRight } from '@phosphor-icons/react/ArrowRight'; +import { ArrowsClockwiseIcon as ArrowsClockwise } from '@phosphor-icons/react/ArrowsClockwise'; +import { CaretLeftIcon as CaretLeft } from '@phosphor-icons/react/CaretLeft'; +import { CaretRightIcon as CaretRight } from '@phosphor-icons/react/CaretRight'; +import { CheckIcon as Check } from '@phosphor-icons/react/Check'; +import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; +import { PathIcon as Path } from '@phosphor-icons/react/Path'; +import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; + +export { + ArrowLeft, + ArrowRight, + ArrowsClockwise, + CaretLeft, + CaretRight, + Check, + GitBranch, + Path, + ShareNetwork, +}; diff --git a/core/index.ts b/core/index.ts index f3066731..9577a60e 100644 --- a/core/index.ts +++ b/core/index.ts @@ -107,3 +107,24 @@ export type { WalkthroughShareManifestV1, WalkthroughHunk, } from './types.ts'; +export { + formatVersionElapsedDuration, + MergeRequestReviewApp, + ReadOnlyGeneralCommentCard, + ReviewSurface, + type MergeRequestCommitListEntry, + type MergeRequestReviewAppProps, + type MergeRequestReviewMode, + type MergeRequestReviewStrategySummary, + type MergeRequestVersionBaseMovement, + type MergeRequestVersionBaseMovementCommit, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCommitEvolutionUnit, + type MergeRequestVersionCommitSummary, + type MergeRequestVersionCompareCommentAssociation, + type MergeRequestVersionCompareSummary, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, + type MergeRequestVersionRebaseDriverCommit, + type MergeRequestWalkthroughStatus, +} from './SharedWalkthroughApp.tsx'; diff --git a/core/lib/diff.ts b/core/lib/diff.ts index 841011a4..61084a62 100644 --- a/core/lib/diff.ts +++ b/core/lib/diff.ts @@ -12,7 +12,8 @@ export const getItemId = (section: DiffSection) => `diff:${section.id}`; export const isMarkdownFilePath = (path: string) => /\.md$/i.test(path); -const isImageFilePath = (path: string) => /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|webp)$/i.test(path); +export const isImageFilePath = (path: string) => + /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|webp)$/i.test(path); export const canRenderImagePreview = (path: string, section: DiffSection) => isImageFilePath(path) && @@ -52,7 +53,7 @@ const fileDiffSectionLookup = new WeakMap< export const getSectionForFileDiff = (fileDiff: FileDiffMetadata) => fileDiffSectionLookup.get(fileDiff); -const getLoadedSectionContents = (file: ChangedFile, section: DiffSection) => +export const getLoadedSectionContents = (file: ChangedFile, section: DiffSection) => loadedSectionContents.get(getLoadedContentsKey(file, section)); export const loadSectionContents = ( @@ -218,7 +219,7 @@ export const getTotalDiffLineCount = (lineCounts: Iterable): Diff export const formatLineCountNumber = (value: number) => value.toLocaleString('en-US'); -const formatCompactLineCountNumber = (value: number) => { +export const formatCompactLineCountNumber = (value: number) => { if (value < 1000) { return String(value); } @@ -237,7 +238,7 @@ const formatCompactLineCountNumber = (value: number) => { export const formatTreeLineCount = ({ additions, deletions }: DiffLineCount) => `+${formatCompactLineCountNumber(additions)} -${formatCompactLineCountNumber(deletions)}`; -const pluralizeLine = (count: number) => (count === 1 ? 'line' : 'lines'); +export const pluralizeLine = (count: number) => (count === 1 ? 'line' : 'lines'); export const getDiffLineCountTitle = ({ additions, deletions }: DiffLineCount) => `${formatLineCountNumber(additions)} added ${pluralizeLine( diff --git a/core/lib/narrative-walkthrough.ts b/core/lib/narrative-walkthrough.ts index 000c29e1..1d5639bf 100644 --- a/core/lib/narrative-walkthrough.ts +++ b/core/lib/narrative-walkthrough.ts @@ -17,7 +17,7 @@ import { isSyntheticWalkthroughHunk, } from './narrative-walkthrough-diff.js'; -type NarrativeLineCount = { +export type NarrativeLineCount = { added: number; deleted: number; }; @@ -29,12 +29,12 @@ export type WalkthroughStopView = WalkthroughStop & { }; /** A chapter with indexed stops. */ -type WalkthroughChapterView = Omit & { +export type WalkthroughChapterView = Omit & { stops: ReadonlyArray; }; /** Support grouped by reason, preserving first-seen order. */ -type WalkthroughSupportReason = { +export type WalkthroughSupportReason = { files: ReadonlyArray; reason: string; }; @@ -338,19 +338,21 @@ export const resolveWalkthroughHunkFile = ( hunk: WalkthroughHunk, files: ReadonlyArray, ): ResolvedWalkthroughHunkFile | null => { - const file = files.find((candidate) => candidate.path === hunk.path); - if (!file) { + const candidates = files.filter((candidate) => candidate.path === hunk.path); + if (candidates.length === 0) { return null; } - - const section = hunk.anchor.sectionId - ? file.sections.find((candidate) => candidate.id === hunk.anchor.sectionId) - : undefined; - if (!section) { + const sectionId = hunk.anchor.sectionId; + if (!sectionId) { return null; } - - return { file, section }; + for (const file of candidates) { + const section = file.sections.find((candidate) => candidate.id === sectionId); + if (section) { + return { file, section }; + } + } + return null; }; const walkthroughHunkRunKey = (item: WalkthroughHunkGroup, hunks: ReadonlyArray) => diff --git a/core/react.ts b/core/react.ts index ea6e4a3a..c581101e 100644 --- a/core/react.ts +++ b/core/react.ts @@ -8,11 +8,46 @@ export { PlanReviewSurface, type PlanReviewCommenting, } from './SharedPlanApp.tsx'; +export { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; export { + formatVersionElapsedDuration, + MergeRequestReviewApp, ReadOnlyGeneralCommentCard, ReviewSurface, + type MergeRequestCommitListEntry, + type MergeRequestReviewAppProps, + type MergeRequestReviewMode, + type MergeRequestReviewStrategySummary, + type MergeRequestVersionBaseMovement, + type MergeRequestVersionBaseMovementCommit, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCommitEvolutionUnit, + type MergeRequestVersionCommitSummary, + type MergeRequestVersionCompareCommentAssociation, + type MergeRequestVersionCompareSummary, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, + type MergeRequestVersionRebaseDriverCommit, + type MergeRequestWalkthroughStatus, type ReviewCommenting, type ReviewMode, type ReviewSurfaceProps, type ReviewWalkthroughStatus, } from './SharedWalkthroughApp.tsx'; +export type { + DiffComparison, + DiffComparisonAnalysis, + DiffComparisonView, + DiffRange, + ReviewCommitEvolution, + ReviewCommitListEntry, + ReviewEvolutionUnit, + ReviewPlan, + ReviewStrategySummary, + ReviewStructureRecommendation, + ReviewUnit, + ReviewVersionOption, + RevisionLabel, + RevisionRef, + WalkthroughGenerationInput, +} from './types.ts'; diff --git a/core/walkthrough.css b/core/walkthrough.css index 5142685f..bcc10586 100644 --- a/core/walkthrough.css +++ b/core/walkthrough.css @@ -8,12 +8,36 @@ border-bottom: 1px solid var(--sidebar-border); padding: 0 8px 8px; } +.wt-focus-header { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + min-width: 0; +} .wt-focus-label { color: var(--text); font: 700 10px/1 var(--font-sans); letter-spacing: 0.06em; text-transform: uppercase; } +.wt-regen-button { + background: transparent; + border: 1px solid rgb(127 127 127 / 0.22); + border-radius: 999px; + color: var(--muted); + cursor: pointer; + flex: none; + font: 9px/1 var(--font-mono); + letter-spacing: 0.04em; + padding: 4px 7px; + text-transform: uppercase; + white-space: nowrap; +} +.wt-regen-button:hover { + background: rgb(127 127 127 / 0.12); + color: var(--sidebar-text); +} .wt-focus p { color: var(--sidebar-text); font: 12px/1.5 var(--font-sans); @@ -38,6 +62,43 @@ display: flex; flex-direction: column; } +.wt-toc-chapter.commit-boundary { + border-top: 1px solid var(--file-border); + margin-top: 8px; + padding-top: 8px; +} +.wt-toc-chapter.commit-boundary:first-child { + margin-top: 0; +} +.wt-commit-boundary-label { + align-items: center; + color: var(--muted); + display: grid; + font: 10px/1.25 var(--font-mono); + gap: 6px; + grid-template-columns: auto auto minmax(0, 1fr); + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 2px 7px; +} +.wt-commit-boundary-label code { + color: var(--sidebar-text); +} +.wt-commit-boundary-label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wt-commit-evolution-indicator { + font-weight: 700; + flex-shrink: 0; +} +.wt-commit-evolution-indicator.evolution-added { color: var(--diff-addition); } +.wt-commit-evolution-indicator.evolution-removed { color: var(--diff-deletion); } +.wt-commit-evolution-indicator.evolution-revised { color: #d9962f; } +.wt-commit-evolution-indicator.evolution-ambiguous { color: var(--muted); } +.wt-commit-evolution-indicator.evolution-unchanged { color: var(--muted); } .wt-toc-chapter-head { align-items: center; display: grid; @@ -329,6 +390,46 @@ gap: 12px; margin: 0 0 12px; } +.wt-comment-references { + display: grid; + gap: 8px; + margin-top: 12px; +} +.wt-comment-reference { + background: color-mix(in srgb, var(--tree-selection-focus) 7%, transparent); + border-left: 3px solid var(--tree-selection-focus); + border-radius: 4px; + padding: 8px 10px; +} +.wt-comment-reference.resolved-by-change { border-left-color: var(--addition); } +.wt-comment-reference > div { align-items: baseline; display: flex; gap: 8px; justify-content: space-between; } +.wt-comment-reference a, +.wt-comment-reference strong { color: var(--text); font: 600 12px/1.35 var(--font-sans); } +.wt-comment-reference span { color: var(--muted); font: 11px/1.35 var(--font-sans); } +.wt-comment-reference blockquote { + color: var(--muted); + font: 12px/1.45 var(--font-sans); + margin: 6px 0 0; + white-space: pre-wrap; +} +.wt-regenerate { + align-items: center; + background: color-mix(in srgb, var(--tree-selection-focus) 13%, transparent); + border: 0; + border-radius: 14px; + color: var(--tree-selection-focus); + corner-shape: squircle; + cursor: pointer; + display: inline-flex; + flex: none; + font: 600 12px/1 var(--font-sans); + gap: 6px; + padding: 7px 12px; +} +.wt-regenerate:disabled { + cursor: default; + opacity: 0.6; +} /* ---- Variation C — hybrid arc timeline ------------------------------------ */ .wt-hybrid { display: flex; @@ -453,6 +554,14 @@ .wt-arc-chapter-label.muted { color: color-mix(in srgb, var(--muted) 85%, transparent); } +.wt-arc-commit-ref { + background: color-mix(in srgb, var(--tree-selection-focus) 14%, transparent); + border-radius: 4px; + color: var(--tree-selection-focus); + font: 9px/1.3 var(--font-mono); + padding: 1px 4px; + text-transform: none; +} .wt-arc-chapter-label .icon { font-size: 13px; } @@ -561,6 +670,37 @@ .wt-stop-block-header.current { box-shadow: inset 3px 0 0 var(--tree-selection-focus); } +.wt-main-commit-section { + margin: 0 0 20px; +} +.wt-main-commit-boundary { + max-width: 100%; + min-width: 0; + overflow: hidden; + align-items: baseline; + background: color-mix(in srgb, var(--tree-selection-focus) 8%, var(--app-bg)); + border-block: 1px solid color-mix(in srgb, var(--tree-selection-focus) 30%, var(--file-border)); + display: flex; + gap: 8px; + padding: 10px 24px; +} +.wt-main-commit-boundary > span { + color: var(--muted); + font: 700 10px/1 var(--font-sans); + text-transform: uppercase; +} +.wt-main-commit-boundary code { + color: var(--tree-selection-focus); + font: 11px/1.2 var(--font-mono); +} +.wt-main-commit-boundary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font: 600 12px/1.3 var(--font-sans); +} /* The whole order as one continuous scroll: each stop is a block (narration + diff) stacked top-to-bottom, separated by a rule, with the scroll-focused stop @@ -1284,7 +1424,7 @@ display: flex; gap: 8px; } -.codiff-button.wt-commit-btn { +.codiff-open-button.wt-commit-btn { background: var(--viewed); border-color: color-mix(in srgb, var(--viewed) 46%, transparent); box-shadow: @@ -1294,7 +1434,7 @@ 0 10px 30px -16px color-mix(in srgb, var(--viewed) 90%, transparent); color: #fff; } -.codiff-button.wt-commit-btn:hover:not(:disabled) { +.codiff-open-button.wt-commit-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--viewed) 92%, #fff); box-shadow: 0 0 0 1px rgb(255 255 255 / 0.2) inset, @@ -1303,10 +1443,10 @@ 0 10px 30px -16px color-mix(in srgb, var(--viewed) 90%, transparent); transform: scale(1.05); } -.codiff-button.wt-commit-btn:active:not(:disabled) { +.codiff-open-button.wt-commit-btn:active:not(:disabled) { transform: scale(0.95); } -.codiff-button.wt-commit-btn:disabled { +.codiff-open-button.wt-commit-btn:disabled { background: var(--hover-wash); border-color: rgb(127 127 127 / 0.18); box-shadow: none; @@ -1316,3 +1456,115 @@ color: #fff; font: inherit; } + +.wt-commit-boundary-label { + align-items: center; + display: grid; + gap: 6px; + grid-template-columns: auto auto minmax(0, 1fr); + max-width: 100%; + min-width: 0; + overflow: hidden; +} +.wt-commit-boundary-label code { + flex: none; + white-space: nowrap; +} +.wt-commit-boundary-subject { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-commit-rebase-badge { + background: color-mix(in srgb, #d9962f 14%, transparent); + border: 1px solid color-mix(in srgb, #d9962f 35%, transparent); + border-radius: 4px; + color: #d9962f; + font: 10px/1.35 var(--font-sans); + grid-column: 1 / -1; + min-width: 0; + padding: 2px 5px; + width: fit-content; +} +.wt-commit-rebase-badge:focus-visible { + outline: 2px solid var(--tree-selection-focus); + outline-offset: 2px; +} +.wt-main-rebase-context { + border-bottom: 1px solid color-mix(in srgb, #d9962f 30%, var(--file-border)); + display: grid; + gap: 7px; + padding: 10px 24px 12px; +} +.wt-main-rebase-context > strong { + color: #d9962f; + font: 600 11px/1.35 var(--font-sans); +} +.wt-main-rebase-context.legacy > span { + color: var(--muted); + font: 11px/1.45 var(--font-sans); +} +.wt-main-rebase-drivers { + display: grid; + gap: 5px; +} +.wt-main-rebase-drivers > a, +.wt-main-rebase-drivers > div { + color: inherit; + display: grid; + gap: 2px 7px; + grid-template-columns: auto minmax(0, 1fr); + text-decoration: none; +} +.wt-main-rebase-drivers code { + color: var(--tree-selection-focus); + font: 10px/1.4 var(--font-mono); +} +.wt-main-rebase-drivers span { + font: 11px/1.4 var(--font-sans); + min-width: 0; + overflow-wrap: anywhere; +} +.wt-main-rebase-drivers small { + color: var(--muted); + font: 10px/1.4 var(--font-sans); + grid-column: 2; +} +.wt-version-base-context { + border-bottom: 1px solid var(--file-border); + display: grid; + gap: 5px; + padding: 12px 24px; +} +.wt-version-base-context > strong { + font: 600 12px/1.4 var(--font-sans); +} +.wt-version-base-context > span, +.wt-version-base-context summary { + color: var(--muted); + font: 11px/1.45 var(--font-sans); +} +.wt-version-base-context summary { + cursor: pointer; +} +.wt-version-base-context details > div { + display: grid; + gap: 4px; + margin-top: 6px; +} +.wt-version-base-context details > div > div { + color: inherit; + display: grid; + gap: 7px; + grid-template-columns: auto minmax(0, 1fr); +} +.wt-version-base-context .git-commit-ref-trigger { + color: var(--tree-selection-focus); + font: 10px/1.4 var(--font-mono); +} +.wt-version-base-context details > div > div > span { + font: 11px/1.4 var(--font-sans); + overflow-wrap: anywhere; +} From 885758f8f2d1ddd1579f14913abbeccc2a80f1a1 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:31 -0500 Subject: [PATCH 12/20] Use provider-neutral review comment coordinates Remove shared walkthrough section identifiers from provider-facing comment payloads and recover the rendered diff section from file, side, and line coordinates. Update Core and Codiff Web callers so persisted comments use one provider-neutral contract without losing their in-app placement. --- core/__tests__/review-comments.test.ts | 44 +------- core/lib/review-comments.ts | 135 +++++++++++++++-------- core/types.ts | 1 - service/react.test.ts | 1 - web/src/sharing/WalkthroughPage.test.tsx | 50 +++++++++ web/src/sharing/WalkthroughPage.tsx | 4 +- 6 files changed, 147 insertions(+), 88 deletions(-) diff --git a/core/__tests__/review-comments.test.ts b/core/__tests__/review-comments.test.ts index dcdfb5af..fce91aeb 100644 --- a/core/__tests__/review-comments.test.ts +++ b/core/__tests__/review-comments.test.ts @@ -5,8 +5,6 @@ import { getPendingPullRequestReviewComments, getReviewCommentsFromState, getVisibleReviewComments, - mergeReviewComments, - toSubmittedReviewComment, toPullRequestReviewComment, } from '../lib/review-comments.ts'; import type { RepositoryState } from '../types.ts'; @@ -74,7 +72,7 @@ test('getReviewCommentsFromState carries the outdated flag through to review com expect(comments.find((comment) => comment.id === 'github:2')?.isOutdated).toBeUndefined(); }); -test('getReviewCommentsFromState hydrates shared comments on their exact working-tree section', () => { +test('getReviewCommentsFromState derives the working-tree section from line coordinates', () => { const state = createPullRequestState(); state.source = { type: 'working-tree' }; state.files = [ @@ -85,13 +83,15 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working binary: false, id: 'src/a.ts:staged', kind: 'staged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new', }, { binary: false, id: 'src/a.ts:unstaged', kind: 'unstaged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+new', }, ], }, @@ -103,7 +103,6 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working filePath: 'src/a.ts', id: 'shared:1', lineNumber: 5, - sectionId: 'src/a.ts:unstaged', side: 'additions', }, ]; @@ -309,39 +308,8 @@ test('serializes file-level thread replies without inventing line metadata', () }); }); -test('serializes section identity only for shared walkthrough comments', () => { +test('omits UI-only section identity from review comment payloads', () => { const comment = createReviewComment({ body: 'Persist this comment.' }); expect(toPullRequestReviewComment(comment)).not.toHaveProperty('sectionId'); - expect(toPullRequestReviewComment(comment, { includeSectionId: true })).toMatchObject({ - sectionId: 'src/a.ts:pull-request:1', - }); -}); - -test('keeps a submitted shared comment visible until the matching snapshot comment arrives', () => { - const draft = createReviewComment({ - id: 'draft-comment', - remoteSubmit: { status: 'submitting' }, - }); - const submitted = toSubmittedReviewComment( - { - author: { login: 'ada', name: 'Ada Lovelace' }, - body: draft.body, - canDelete: true, - canEdit: true, - filePath: draft.filePath, - id: 'persisted-comment', - lineNumber: draft.lineNumber, - sectionId: draft.sectionId, - side: draft.side, - submittedAt: '2026-07-16T12:00:00.000Z', - threadId: 'persisted-thread', - }, - draft, - ); - - expect(mergeReviewComments([], [submitted])).toEqual([submitted]); - - const snapshotComment = { ...submitted, body: 'Canonical server comment.' }; - expect(mergeReviewComments([snapshotComment], [submitted])).toEqual([snapshotComment]); }); diff --git a/core/lib/review-comments.ts b/core/lib/review-comments.ts index e2b24a60..85365916 100644 --- a/core/lib/review-comments.ts +++ b/core/lib/review-comments.ts @@ -1,7 +1,7 @@ +import type { CodeViewLineSelection } from '@pierre/diffs'; import type { ChangedFile, DiffSection, - PullRequestExistingReviewComment, PullRequestReviewComment, RepositoryState, } from '../types.ts'; @@ -31,6 +31,18 @@ export const isLineReviewComment = ( ): comment is ReviewComment & { lineNumber: number; side: 'additions' | 'deletions' } => !isFileReviewComment(comment); +export const getReviewCommentLineSelection = (comment: ReviewComment): CodeViewLineSelection => ({ + id: `diff:${comment.sectionId}`, + range: { + end: comment.lineNumber ?? 1, + ...(comment.startSide != null && comment.startSide !== comment.side + ? { endSide: comment.side } + : {}), + side: comment.startSide ?? comment.side ?? 'additions', + start: comment.startLineNumber ?? comment.lineNumber ?? 1, + }, +}); + type ReviewPatchRow = { additionLineNumber?: number; deletionLineNumber?: number; @@ -67,9 +79,10 @@ export function updateStickyHeaderState(viewer: CodeViewInstance) { } } -const getReviewSideLabel = (side: ReviewComment['side']) => (side === 'additions' ? 'New' : 'Old'); +export const getReviewSideLabel = (side: ReviewComment['side']) => + side === 'additions' ? 'New' : 'Old'; -const getReviewCommentStartSide = (comment: Pick) => +export const getReviewCommentStartSide = (comment: Pick) => comment.startSide ?? comment.side; export const getReviewCommentLineLabel = ( @@ -116,53 +129,15 @@ export const getReviewCommentRangeProps = ( : {}; }; -export const toPullRequestReviewComment = ( - comment: ReviewComment, - { includeSectionId = false }: { includeSectionId?: boolean } = {}, -): PullRequestReviewComment => ({ +export const toPullRequestReviewComment = (comment: ReviewComment): PullRequestReviewComment => ({ body: comment.body, filePath: comment.filePath, ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - ...(includeSectionId ? { sectionId: comment.sectionId } : {}), ...(comment.side ? { side: comment.side } : {}), ...getReviewCommentRangeProps(comment), ...(comment.threadId ? { threadId: comment.threadId } : {}), }); -export const toSubmittedReviewComment = ( - comment: PullRequestExistingReviewComment, - draft: ReviewComment, -): ReviewComment => ({ - ...(comment.anchor ? { anchor: comment.anchor } : {}), - author: comment.author, - body: comment.body, - ...(comment.canDelete ? { canDelete: true } : {}), - ...(comment.canEdit ? { canEdit: true } : {}), - ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), - ...(comment.canResolveThread ? { canResolveThread: true } : {}), - filePath: comment.filePath, - id: comment.id, - ...(comment.isOutdated ? { isOutdated: true } : {}), - isReadOnly: true, - ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), - ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - sectionId: comment.sectionId ?? draft.sectionId, - ...(comment.side ? { side: comment.side } : {}), - ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), - ...(comment.startSide ? { startSide: comment.startSide } : {}), - ...(comment.submittedAt ? { submittedAt: comment.submittedAt } : {}), - ...(comment.threadId ? { threadId: comment.threadId } : {}), - ...(comment.url ? { url: comment.url } : {}), -}); - -export const mergeReviewComments = ( - snapshotComments: ReadonlyArray, - localComments: ReadonlyArray, -): ReadonlyArray => { - const snapshotIds = new Set(snapshotComments.map((comment) => comment.id)); - return [...snapshotComments, ...localComments.filter((comment) => !snapshotIds.has(comment.id))]; -}; - const isPendingPullRequestReviewComment = (comment: ReviewComment) => !comment.isReadOnly && !comment.threadId && @@ -246,7 +221,7 @@ const trimReviewPatchLineTerminator = (line: string) => const getReviewPatchText = (lines: ReadonlyArray, index: number) => trimReviewPatchLineTerminator(lines[index] ?? ''); -const getReviewCommentPatchContext = ( +export const getReviewCommentPatchContext = ( file: ChangedFile, section: DiffSection, comment: ReviewComment, @@ -337,6 +312,77 @@ const getReviewCommentPatchContext = ( return section.summary?.reason || section.patch.trim() || 'No patch context available.'; }; +export const getReviewCommentSection = ( + file: ChangedFile, + comment: Pick, + showWhitespace: boolean, +) => { + if (isFileReviewComment(comment)) { + return file.sections[0]; + } + const side = comment.side ?? 'additions'; + const line = comment.lineNumber ?? 1; + const startLine = comment.startLineNumber ?? line; + const startSide = comment.startSide ?? side; + return ( + file.sections.find((section) => { + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + let hasStart = false; + let hasEnd = false; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + if ( + (startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.lines) || + (startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.lines) + ) { + hasStart = true; + } + if ( + (side === 'additions' && line >= newLine && line < newLine + content.lines) || + (side === 'deletions' && line >= oldLine && line < oldLine + content.lines) + ) { + hasEnd = true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + if (side === 'deletions' && line >= oldLine && line < oldLine + content.deletions) { + hasEnd = true; + } + if ( + startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.deletions + ) { + hasStart = true; + } + if (side === 'additions' && line >= newLine && line < newLine + content.additions) { + hasEnd = true; + } + if ( + startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.additions + ) { + hasStart = true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return hasStart && hasEnd; + }); + }) ?? file.sections[0] + ); +}; + export const buildReviewCommentsMarkdown = ( files: ReadonlyArray, comments: ReadonlyArray, @@ -383,8 +429,7 @@ export const buildReviewCommentsMarkdown = ( export const getReviewCommentsFromState = (state: RepositoryState): ReadonlyArray => (state.reviewComments ?? []).flatMap((comment) => { const file = state.files.find((candidate) => candidate.path === comment.filePath); - const section = - file?.sections.find((candidate) => candidate.id === comment.sectionId) ?? file?.sections[0]; + const section = file ? getReviewCommentSection(file, comment, false) : undefined; return section ? [ { diff --git a/core/types.ts b/core/types.ts index 161ccd1c..4c9103bc 100644 --- a/core/types.ts +++ b/core/types.ts @@ -1107,7 +1107,6 @@ export type PullRequestReviewComment = { body: string; filePath: string; lineNumber?: number; - sectionId?: string; side?: 'additions' | 'deletions'; startLineNumber?: number; startSide?: 'additions' | 'deletions'; diff --git a/service/react.test.ts b/service/react.test.ts index d28f0c45..a84116e8 100644 --- a/service/react.test.ts +++ b/service/react.test.ts @@ -5,7 +5,6 @@ const comment = { body: 'Keep this visible while it saves.', filePath: 'src/review.ts', lineNumber: 12, - sectionId: 'src/review.ts:unstaged', side: 'additions', } as const; diff --git a/web/src/sharing/WalkthroughPage.test.tsx b/web/src/sharing/WalkthroughPage.test.tsx index 1ddc1029..15783bbb 100644 --- a/web/src/sharing/WalkthroughPage.test.tsx +++ b/web/src/sharing/WalkthroughPage.test.tsx @@ -179,3 +179,53 @@ test('optimistically adds walkthrough-level comments and replies', async () => { view: {}, }); }); + +test('persists diff comments using provider-neutral line coordinates', async () => { + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const commenting = rendered.props?.commenting as { + onSubmitComment(comment: { + body: string; + filePath: string; + lineNumber: number; + side: 'additions'; + }): Promise; + }; + await commenting.onSubmitComment({ + body: 'Review this line.', + filePath: 'src/review.ts', + lineNumber: 12, + side: 'additions', + }); + + expect(fate.client.mutations.shareComment.createThread).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + body: 'Review this line.', + shareId: 'walkthrough-id', + shareType: 'walkthrough', + target: { + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + side: 'additions', + }, + }, + optimistic: expect.objectContaining({ + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + sectionId: null, + side: 'additions', + }), + view: {}, + }), + ); +}); diff --git a/web/src/sharing/WalkthroughPage.tsx b/web/src/sharing/WalkthroughPage.tsx index 3ad5e9b9..b8c18800 100644 --- a/web/src/sharing/WalkthroughPage.tsx +++ b/web/src/sharing/WalkthroughPage.tsx @@ -81,7 +81,6 @@ const reviewComments = ( ? { anchor: 'file' as const } : { lineNumber: thread.lineNumber!, - ...(thread.sectionId ? { sectionId: thread.sectionId } : {}), side: thread.side!, ...(thread.startLineNumber ? { startLineNumber: thread.startLineNumber } : {}), ...(thread.startSide ? { startSide: thread.startSide } : {}), @@ -140,7 +139,6 @@ const commentTarget = (comment: PullRequestReviewComment) => { filePath: comment.filePath, kind: 'walkthrough-diff' as const, lineNumber: comment.lineNumber, - ...(comment.sectionId ? { sectionId: comment.sectionId } : {}), side: comment.side, ...(comment.startLineNumber ? { startLineNumber: comment.startLineNumber } : {}), ...(comment.startSide ? { startSide: comment.startSide } : {}), @@ -288,7 +286,7 @@ const Viewer = ({ walkthrough: walkthroughRef }: { walkthrough: ViewRef<'Walkthr ], planId: null, resolvedAt: null, - sectionId: fileComment ? null : (target.sectionId ?? null), + sectionId: null, side: fileComment ? null : target.side, startLineNumber: fileComment ? null : (target.startLineNumber ?? null), startSide: fileComment ? null : (target.startSide ?? null), From 2b267978b88f45ffd3a9dd174bd94d4e71552e87 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 21:50:17 -0500 Subject: [PATCH 13/20] Explain commit evolution in shared review UI Present introduced, removed, revised, retained, ambiguous, and absorbed commits with consistent labels in tooltips, comparison lists, and walkthrough chapter boundaries. Carry the classification through walkthrough authoring so reviewers can understand why a logical change moved or was rewritten without reading provider-specific metadata. --- core/App.css | 37 +++- core/SharedWalkthroughApp.tsx | 42 ++-- core/__tests__/walkthrough-authoring.test.ts | 23 +++ core/app/components/CommitRefTooltip.tsx | 37 +++- core/app/components/CommitScopePanel.tsx | 5 + .../walkthrough/NarrativeSidebar.tsx | 117 ++++++----- .../walkthrough/NarrativeWalkthroughView.tsx | 193 +++++++++--------- core/index.ts | 4 + core/lib/walkthrough-authoring.ts | 106 ++++++++-- core/types.ts | 24 ++- core/walkthrough-authoring.ts | 2 + core/walkthrough.css | 32 ++- 12 files changed, 403 insertions(+), 219 deletions(-) diff --git a/core/App.css b/core/App.css index 57ea8dd4..871addd9 100644 --- a/core/App.css +++ b/core/App.css @@ -1480,20 +1480,37 @@ html[data-codiff-platform='darwin'] .sidebar { .version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .version-commit-unit.unchanged { opacity: 0.5; } .version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } -.version-commit-kind { font-weight: 700; } -.version-commit-kind.added { color: var(--diff-addition); } -.version-commit-kind.removed { color: var(--diff-deletion); } -.version-commit-kind.likely-revised, -.version-commit-kind.revised { +.version-commit-kind-pill { + align-items: center; + background: color-mix(in srgb, currentColor 12%, transparent); + border: 1px solid color-mix(in srgb, currentColor 32%, transparent); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font: 700 9px/1 var(--font-sans); + justify-self: start; + letter-spacing: 0.01em; + padding: 3px 5px; + white-space: nowrap; +} + +.version-commit-kind-pill.added, +.version-commit-kind-pill.introduced { + color: var(--diff-addition); +} +.version-commit-kind-pill.removed { + color: var(--diff-deletion); +} +.version-commit-kind-pill.likely-revised, +.version-commit-kind-pill.revised { color: #d9962f; } -.version-commit-kind.introduced { - color: #3f9a5a; +.version-commit-kind-pill.absorbed-into-base, +.version-commit-kind-pill.ambiguous, +.version-commit-kind-pill.unchanged { + color: var(--muted); } -.version-commit-kind.absorbed-into-base { color: var(--muted); } -.version-commit-kind.ambiguous { color: var(--muted); } -.version-commit-kind.unchanged { color: var(--muted); } .version-commit-unit-block { display: grid; gap: 1px; } .version-commit-rebase-drivers { display: grid; diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index 987b0040..94545c8a 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -23,7 +23,10 @@ import { } from 'react'; import { Avatar } from './app/components/Avatar.tsx'; import { Button } from './app/components/Button.tsx'; -import { CommitRefTooltip } from './app/components/CommitRefTooltip.tsx'; +import { + CommitRefTooltip, + versionCommitKindLabel, +} from './app/components/CommitRefTooltip.tsx'; import { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; import { isTerminalPullRequestMergeState, @@ -3513,10 +3516,9 @@ export function ReviewSurface({ key={commit.sha} > - {isBackward ? '−' : '+'} + {isBackward ? 'Removed' : 'Added'} {commit.subject} @@ -3576,18 +3578,12 @@ export function ReviewSurface({ unit.kind === 'retained' || unit.kind === 'rewritten-same-patch' || unit.kind === 'absorbed-into-base'; - const symbol = - unit.kind === 'introduced' - ? '+' - : unit.kind === 'removed' - ? '−' - : unit.kind === 'revised' - ? '~' - : unit.kind === 'absorbed-into-base' - ? '↳' - : unit.kind === 'ambiguous' - ? '?' - : '·'; + const versionCommitKind = + unit.kind !== 'commit' && + 'after' in unit && + unit.after?.sha === commit.sha + ? unit.kind + : undefined; const kindClass = unit.kind === 'absorbed-into-base' ? 'absorbed-into-base' @@ -3639,7 +3635,11 @@ export function ReviewSurface({ } type="button" > - {symbol} + + {unit.kind === 'commit' + ? 'Commit' + : versionCommitKindLabel(unit.kind)} + - - ~ + + Rebase driver { expect(composed.title).toContain('v1'); }); +test('scopes version-comparison Review focus to changes since the earlier version', () => { + const overviewPrompt = buildVersionCommitOverviewPrompt({ + entries: [ + { + context: { + after: { shortSha: 'bbbbbbb', subject: 'Later' }, + evolutionKind: 'added', + kind: 'version-commit', + range: { fromLabel: 'v1', toLabel: 'v2' }, + unitId: 'unit-1', + }, + state, + walkthrough: null, + }, + ], + range: { fromLabel: 'v1', toLabel: 'v2' }, + }); + expect(overviewPrompt).toContain('strictly the changes since v1, through v2'); + expect(overviewPrompt).toContain('Do not summarize the merge request as a whole'); + expect(overviewPrompt).toContain('behavior already present in v1 as newly introduced'); +}); + test('exposes prompt digest sizing and patch budgets', () => { const { digest, patchBudgets, size } = buildWalkthroughPromptInput(state); expect(size.hunkCount).toBe(2); diff --git a/core/app/components/CommitRefTooltip.tsx b/core/app/components/CommitRefTooltip.tsx index f4c2b98c..1dcf7827 100644 --- a/core/app/components/CommitRefTooltip.tsx +++ b/core/app/components/CommitRefTooltip.tsx @@ -1,5 +1,6 @@ import { Tooltip } from '@base-ui/react/tooltip'; import { ExternalLink } from 'lucide-react'; +import type { VersionCommitKind } from '../../types.ts'; export type CommitRefSummary = { additions?: number; @@ -9,9 +10,28 @@ export type CommitRefSummary = { sha: string; shortSha: string; subject?: string; + /** Present only for commits from the later side of a version comparison. */ + versionCommitKind?: VersionCommitKind; webUrl?: string; }; - +export const versionCommitKindLabel = (kind: VersionCommitKind) => { + switch (kind) { + case 'introduced': + return 'Added'; + case 'removed': + return 'Removed'; + case 'revised': + return 'Revised'; + case 'retained': + return 'Unchanged'; + case 'rewritten-same-patch': + return 'Same patch'; + case 'absorbed-into-base': + return 'In target base'; + case 'ambiguous': + return 'Unclassified'; + } +}; export function CommitRefTooltip({ className, commit, @@ -26,12 +46,7 @@ export function CommitRefTooltip({ const triggerClassName = ['git-commit-ref-trigger', className].filter(Boolean).join(' '); const trigger = linkTrigger && commit.webUrl ? ( - + ) : ( ); @@ -49,6 +64,14 @@ export function CommitRefTooltip({ {commit.subject || 'Commit'} {commit.sha} + {commit.versionCommitKind ? ( + + {versionCommitKindLabel(commit.versionCommitKind)} + + ) : null} {commit.authorName || authoredLabel ? ( {[commit.authorName, authoredLabel].filter(Boolean).join(' · ')} diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx index b0244f8d..80913391 100644 --- a/core/app/components/CommitScopePanel.tsx +++ b/core/app/components/CommitScopePanel.tsx @@ -113,6 +113,10 @@ export function CommitScopePanel({ if (!commit || !onToggleVersionUnit) { return null; } + const versionCommitKind = + unit.kind !== 'commit' && 'after' in unit && unit.after?.sha === commit.sha + ? unit.kind + : undefined; return (
diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index 8568d8a7..b774817a 100644 --- a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx +++ b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx @@ -163,7 +163,9 @@ function StopHeader({ current, stop }: { current: boolean; stop: WalkthroughStop
{comment.url ? ( - Review comment by {comment.authorName} + + Review comment by {comment.authorName} + ) : ( Review comment by {comment.authorName} )} @@ -868,110 +870,109 @@ export function NarrativeWalkthroughView({ onTouchStartCapture={navigation.releaseStopScrollLock} onWheelCapture={navigation.releaseStopScrollLock} > + {navigation.mode === 'commit' ? ( + + ) : walkthroughView.sequence.length > 0 ? ( + renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) + ) : ( + renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) + )} - {navigation.mode === 'commit' ? ( - - ) : walkthroughView.sequence.length > 0 ? ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - ) : ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - )} - - {navigation.mode === 'commit' ? null : completionAction ? ( - - ) : navigation.mode === 'stop' && next ? ( - - ) : navigation.mode === 'stop' && supportAvailable ? ( - + ) : navigation.mode === 'stop' && next ? ( + + ) : navigation.mode === 'stop' && supportAvailable ? ( + - ) : navigation.mode === 'stop' && committable ? ( - + ) : navigation.mode === 'stop' && committable ? ( + - ) : navigation.mode === 'support' && committable ? ( - + ) : navigation.mode === 'support' && committable ? ( + - ) : navigation.mode === 'support' ? ( - + ) : navigation.mode === 'support' ? ( + + ) : null}
diff --git a/core/index.ts b/core/index.ts index 9577a60e..98cb2b9a 100644 --- a/core/index.ts +++ b/core/index.ts @@ -46,6 +46,7 @@ export { type GenerateReviewWalkthroughResult, type ReviewWalkthroughModelResult, type ReviewWalkthroughRunModel, + type ReviewWalkthroughRunOverviewModel, } from './lib/generate-review-walkthrough.ts'; export { parsePlanShareManifest, @@ -94,6 +95,7 @@ export type { ReviewStructureRecommendation, ReviewUnit, ReviewVersionOption, + VersionCommitKind, RepositoryState, ReviewSource, RevisionLabel, @@ -103,6 +105,8 @@ export type { SharedPlanSnapshot, SharedWalkthroughSnapshot, WalkthroughCommentReference, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, WalkthroughGenerationInput, WalkthroughShareManifestV1, WalkthroughHunk, diff --git a/core/lib/walkthrough-authoring.ts b/core/lib/walkthrough-authoring.ts index aac60902..9afcb360 100644 --- a/core/lib/walkthrough-authoring.ts +++ b/core/lib/walkthrough-authoring.ts @@ -26,19 +26,19 @@ import { string, union, } from 'valibot'; -import { - getSectionWalkthroughHunks, - isGeneratedWalkthroughPath, -} from './narrative-walkthrough-diff.js'; import type { ChangedFile, DiffSection, NarrativeWalkthrough, RepositoryState, + VersionCommitKind, WalkthroughCommentReference, WalkthroughHunk, } from '../types.ts'; - +import { + getSectionWalkthroughHunks, + isGeneratedWalkthroughPath, +} from './narrative-walkthrough-diff.js'; export const maxProseChars = 4000; export const maxPatchExcerpt = 2500; @@ -277,7 +277,11 @@ const compactNullableGroup = (group: { title?: string | null; }) => ({ ...(group.changeType - ? { changeType: group.changeType as NonNullable } + ? { + changeType: group.changeType as NonNullable< + WalkthroughDraft['chapters'][number]['stops'][number]['changeType'] + >, + } : {}), ...(group.commitNote ? { commitNote: group.commitNote } : {}), hunkIds: [...group.hunkIds], @@ -368,7 +372,6 @@ export type WalkthroughReviewStrategy = reason: string; }; - type IndexedHunk = WalkthroughHunk & { sectionId: string; sectionKind: 'pull-request'; @@ -477,7 +480,11 @@ export const normalizeWalkthroughDraft = ( const used = new Set(); const itemIds = new Set(); - const resolveGroup = (group: WalkthroughDraft["chapters"][number]["stops"][number] | NonNullable[number]) => { + const resolveGroup = ( + group: + | WalkthroughDraft['chapters'][number]['stops'][number] + | NonNullable[number], + ) => { if (itemIds.has(group.id)) { return null; } @@ -500,8 +507,12 @@ export const normalizeWalkthroughDraft = ( return { ...counts, ...(group.changeType - ? { changeType: group.changeType as NonNullable } - : {}), + ? { + changeType: group.changeType as NonNullable< + WalkthroughDraft['chapters'][number]['stops'][number]['changeType'] + >, + } + : {}), ...(group.commitNote ? { commitNote: clean(group.commitNote) } : {}), hunkIds, hunks, @@ -1034,24 +1045,21 @@ Repository digest: ${JSON.stringify(digest)}`; }; - - /** @deprecated Prefer normalizeWalkthroughDraft. */ export const normalizeAuthoredWalkthrough = normalizeWalkthroughDraft; export type UnitWalkthroughEntry = { context: | { - kind: 'mr-commit'; commit: { sha: string; shortSha: string; subject: string; webUrl?: string; }; + kind: 'mr-commit'; } | { - kind: 'version-commit'; after?: { sha: string; shortSha: string; @@ -1065,6 +1073,8 @@ export type UnitWalkthroughEntry = { webUrl?: string; }; commentReferences?: ReadonlyArray; + evolutionKind?: 'likely-revised' | 'added' | 'removed' | 'ambiguous'; + kind: 'version-commit'; range: { fromLabel: string; toLabel: string }; rebaseDrivers?: ReadonlyArray<{ authoredAt?: string; @@ -1080,17 +1090,71 @@ export type UnitWalkthroughEntry = { state: RepositoryState; walkthrough: NarrativeWalkthrough | null; }; +const versionCommitOverviewKindLabel = ( + kind: Extract['evolutionKind'], +) => { + switch (kind) { + case 'added': + return 'added'; + case 'removed': + return 'removed'; + case 'likely-revised': + return 'revised'; + default: + return 'unclassified'; + } +}; + +const toVersionCommitKind = ( + kind: NonNullable< + Extract['evolutionKind'] + >, +): VersionCommitKind => + kind === 'added' ? 'introduced' : kind === 'likely-revised' ? 'revised' : kind; + +/** Build the focused cross-commit summary that appears above a unit walkthrough. */ +export const buildVersionCommitOverviewPrompt = ({ + entries, + range, +}: { + entries: ReadonlyArray; + range: { fromLabel: string; toLabel: string }; +}) => { + const commits = entries.flatMap((entry) => { + if (entry.context.kind !== 'version-commit') { + return []; + } + const { after, before, evolutionKind } = entry.context; + return [ + { + earlierCommit: before ? `${before.shortSha}: ${before.subject}` : null, + kind: versionCommitOverviewKindLabel(evolutionKind), + laterCommit: after ? `${after.shortSha}: ${after.subject}` : null, + unitFocus: entry.walkthrough?.focus ?? null, + }, + ]; + }); + return `Write the Review focus for a commit-by-commit version comparison from ${range.fromLabel} to ${range.toLabel}. + +> Scope is strictly the changes since ${range.fromLabel}, through ${range.toLabel}. Frame every statement as a change that occurred after the earlier selected version. Do not summarize the merge request as a whole, describe behavior already present in ${range.fromLabel} as newly introduced, or infer changes outside this version window. +> The reviewer needs a concise, evidence-based overview of the various changed commits before following the detailed walkthrough. Synthesize the supplied unit summaries; do not merely count commits or repeat their subjects. Mention each commit's kind (added, removed, revised, or unclassified) when it clarifies what changed. Do not describe pure rebase noise as new behavior. Use 2-4 short sentences, no heading or list. + +> Commit context (ordered): +${JSON.stringify(commits)}`; +}; /** * Deterministically compose completed unit walkthroughs into one narrative. */ export const composeUnitWalkthroughs = ({ agent, entries, + focus, state, }: { agent: NarrativeWalkthrough['agent']; entries: ReadonlyArray; + focus?: string; state: RepositoryState; }): NarrativeWalkthrough => { const chapters = entries.flatMap((entry) => { @@ -1113,6 +1177,11 @@ export const composeUnitWalkthroughs = ({ shortSha: summary.shortSha, subject: summary.subject, webUrl: summary.webUrl, + ...(context.kind === 'version-commit' && + context.evolutionKind && + context.after?.sha === summary.sha + ? { versionCommitKind: toVersionCommitKind(context.evolutionKind) } + : {}), ...(rebaseDrivers.length > 0 ? { rebaseDrivers: rebaseDrivers.map((driver) => ({ @@ -1139,9 +1208,12 @@ export const composeUnitWalkthroughs = ({ agent, chapters, commitFiles: entries.flatMap((entry) => entry.state.files), - focus: isVersionComparison - ? `Review ${entries.length} changed commit units in stack order.` - : `Review ${entries.length} commits in topological order, from oldest to newest.`, + focus: clean( + focus ?? '', + isVersionComparison + ? `Review ${entries.length} changed commit units in stack order.` + : `Review ${entries.length} commits in topological order, from oldest to newest.`, + ), generatedAt: new Date().toISOString(), kind: 'narrative', repo: { branch: state.branch, root: state.root }, diff --git a/core/types.ts b/core/types.ts index 4c9103bc..4929401d 100644 --- a/core/types.ts +++ b/core/types.ts @@ -237,10 +237,27 @@ export type CodiffFeatureFlags = { walkthroughSharing: boolean; }; +export type WalkthroughGenerationUnitProgress = { + detail?: string; + id: string; + label: string; + status: 'failed' | 'generating' | 'pending' | 'ready'; +}; + +/** Host/agent progress for an in-flight walkthrough generation. */ +export type WalkthroughGenerationProgress = { + completed?: number; + phase: 'combining' | 'generating' | 'generating-units' | 'preparing'; + summary: string; + total?: number; + units?: ReadonlyArray; +}; + export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; export type WalkthroughProgressEvent = { - phase: WalkthroughProgressPhase; + generation?: WalkthroughGenerationProgress; + phase?: WalkthroughProgressPhase; }; export type CodiffMarkdownDocument = { @@ -564,6 +581,9 @@ export type ReviewEvolutionMarkerUnit = { export type ReviewEvolutionUnit = ReviewUnit | ReviewEvolutionMarkerUnit; +/** Classification of a commit while comparing two review versions. */ +export type VersionCommitKind = Exclude; + export type ReviewEvolutionSummary = { absorbedIntoBase: number; added: number; @@ -885,6 +905,8 @@ export type WalkthroughChapter = { sha: string; shortSha: string; subject: string; + /** Classification when this boundary is a commit from the later comparison version. */ + versionCommitKind?: VersionCommitKind; webUrl?: string; }; icon: WalkthroughIcon; diff --git a/core/walkthrough-authoring.ts b/core/walkthrough-authoring.ts index b18b36cb..63c9ded2 100644 --- a/core/walkthrough-authoring.ts +++ b/core/walkthrough-authoring.ts @@ -1,6 +1,7 @@ export { attachVersionCommentReferences, buildWalkthroughPrompt, + buildVersionCommitOverviewPrompt, buildWalkthroughPromptInput, combineCommitWalkthroughs, composeUnitWalkthroughs, @@ -34,4 +35,5 @@ export { type GenerateReviewWalkthroughResult, type ReviewWalkthroughModelResult, type ReviewWalkthroughRunModel, + type ReviewWalkthroughRunOverviewModel, } from './lib/generate-review-walkthrough.ts'; diff --git a/core/walkthrough.css b/core/walkthrough.css index bcc10586..273530a7 100644 --- a/core/walkthrough.css +++ b/core/walkthrough.css @@ -91,14 +91,8 @@ white-space: nowrap; } .wt-commit-evolution-indicator { - font-weight: 700; flex-shrink: 0; } -.wt-commit-evolution-indicator.evolution-added { color: var(--diff-addition); } -.wt-commit-evolution-indicator.evolution-removed { color: var(--diff-deletion); } -.wt-commit-evolution-indicator.evolution-revised { color: #d9962f; } -.wt-commit-evolution-indicator.evolution-ambiguous { color: var(--muted); } -.wt-commit-evolution-indicator.evolution-unchanged { color: var(--muted); } .wt-toc-chapter-head { align-items: center; display: grid; @@ -401,11 +395,24 @@ border-radius: 4px; padding: 8px 10px; } -.wt-comment-reference.resolved-by-change { border-left-color: var(--addition); } -.wt-comment-reference > div { align-items: baseline; display: flex; gap: 8px; justify-content: space-between; } +.wt-comment-reference.resolved-by-change { + border-left-color: var(--addition); +} +.wt-comment-reference > div { + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; +} .wt-comment-reference a, -.wt-comment-reference strong { color: var(--text); font: 600 12px/1.35 var(--font-sans); } -.wt-comment-reference span { color: var(--muted); font: 11px/1.35 var(--font-sans); } +.wt-comment-reference strong { + color: var(--text); + font: 600 12px/1.35 var(--font-sans); +} +.wt-comment-reference span { + color: var(--muted); + font: 11px/1.35 var(--font-sans); +} .wt-comment-reference blockquote { color: var(--muted); font: 12px/1.45 var(--font-sans); @@ -433,12 +440,17 @@ /* ---- Variation C — hybrid arc timeline ------------------------------------ */ .wt-hybrid { display: flex; + flex: 1; flex-direction: column; height: 100%; min-height: 0; min-width: 0; width: 100%; } + +.codiff-web-review > .wt-arc { + order: -1; +} .wt-arc { align-items: center; background: var(--app-bg); From 0bf5597df9ffe501d39029fb9f1fc7e28cdab4b4 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 21:50:17 -0500 Subject: [PATCH 14/20] Show detailed walkthrough generation progress Expose structured generation progress to the shared review surface and render current phases plus per-unit status while a walkthrough is prepared, generated, and combined. Keep loading and generation messaging host-neutral so Codiff Web can provide useful feedback without coupling the UI to a specific agent runtime. --- core/App.css | 95 ++++++- core/SharedWalkthroughApp.tsx | 24 +- core/__tests__/WalkthroughProgress.test.tsx | 43 ++++ .../generate-review-walkthrough.test.ts | 44 +++- .../walkthrough/WalkthroughProgress.tsx | 65 ++++- core/lib/generate-review-walkthrough.ts | 232 +++++++++++++----- 6 files changed, 415 insertions(+), 88 deletions(-) diff --git a/core/App.css b/core/App.css index 871addd9..113690e7 100644 --- a/core/App.css +++ b/core/App.css @@ -2288,16 +2288,79 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.walkthrough-progress-copy { + display: grid; + gap: 6px; + min-width: 0; + text-align: left; + width: 100%; +} + +.walkthrough-progress-heading { + align-items: baseline; + display: inline-flex; + gap: 0.5ch; + justify-content: center; + max-width: 100%; + min-width: 0; +} + .walkthrough-progress-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.walkthrough-progress-units { + display: grid; + gap: 3px; + list-style: none; + margin: 0; + max-height: 12rem; + overflow: auto; + padding: 0; +} + +.walkthrough-progress-unit { + align-items: baseline; + color: var(--muted, inherit); + display: grid; + font-size: 12px; + font-weight: 400; + gap: 8px; + grid-template-columns: 5.5rem minmax(0, 1fr); + opacity: 0.9; +} + +.walkthrough-progress-unit.is-generating { + color: inherit; + opacity: 1; +} + +.walkthrough-progress-unit.is-ready { + opacity: 0.75; +} + +.walkthrough-progress-unit.is-failed { + color: var(--danger, #a33a3a); +} + +.walkthrough-progress-unit-status { + font-variant-numeric: tabular-nums; + opacity: 0.8; + text-transform: lowercase; +} + +.walkthrough-progress-unit-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .walkthrough-progress-timer { flex: 0 0 2ch; font-variant-numeric: tabular-nums; - margin-left: 0.5ch; text-align: left; visibility: hidden; width: 2ch; @@ -2312,9 +2375,35 @@ html[data-codiff-platform='darwin'] .sidebar { } .walkthrough-generating-main { - display: grid; - gap: 10px; + align-items: center; + box-sizing: border-box; + display: flex; + flex: 1; + height: 100%; + justify-content: center; + min-height: 0; + min-width: 0; + padding: 24px; + width: 100%; +} + +.walkthrough-generating-main .walkthrough-progress, +.walkthrough-generating-main .walkthrough-progress-copy { + width: auto; +} + +.walkthrough-generating-main .walkthrough-progress-copy { justify-items: center; + text-align: center; +} + +.walkthrough-generating-main .walkthrough-progress-label { + white-space: normal; +} + +.walkthrough-generating-main .walkthrough-progress-units { + justify-items: start; + text-align: left; } .walkthrough-generating-main > span { diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index 94545c8a..c33712d7 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -123,6 +123,7 @@ import type { ReviewVersionOption, RepositoryState, SharedWalkthroughSnapshot, + WalkthroughGenerationProgress, WalkthroughCommitMessageResult, WalkthroughCommitResult, } from './types.ts'; @@ -272,6 +273,7 @@ export type MergeRequestReviewAppProps = { versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; walkthrough: NarrativeWalkthrough | null; walkthroughError?: string | null; + walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; }; @@ -1377,6 +1379,7 @@ export type ReviewSurfaceProps = { onVersionCompareRangeChange?: (fromId: string, toId: string) => void; reviewStrategy?: MergeRequestReviewStrategySummary | null; walkthroughError?: string | null; + walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; }; onDeleteShare?: () => Promise | void; @@ -3176,6 +3179,7 @@ export function ReviewSurface({ walkthroughBusy && !walkthroughRequestForce && walkthroughStatus !== 'generating'; const generatingWalkthrough = walkthroughBusy && (walkthroughRequestForce || walkthroughStatus === 'generating'); + const walkthroughGenerationProgress = interactive?.walkthroughProgress ?? null; const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' : computingVersionChanges @@ -3183,8 +3187,8 @@ export function ReviewSurface({ : walkthroughIdle && !walkthroughBusy ? 'Walkthrough not generated' : loadingWalkthroughLookup - ? `Loading ${walkthroughStructurePhrase} walkthrough…` - : `Generating ${walkthroughStructurePhrase} walkthrough…`; + ? 'Loading walkthrough…' + : 'Generating walkthrough…'; const walkthroughStatusDescription = walkthroughFailed ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') : computingVersionChanges @@ -3193,16 +3197,17 @@ export function ReviewSurface({ ? versionCompareActive ? 'Choose a walkthrough structure, then generate it for this version comparison.' : 'Generate a walkthrough to review these changes.' - : (interactive?.walkthroughError ?? + : (walkthroughGenerationProgress?.summary ?? + interactive?.walkthroughError ?? (loadingWalkthroughLookup - ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough for this scope.` - : `Building the ${walkthroughStructurePhrase} walkthrough.`)); + ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough.` + : null)); const walkthroughProgressLabel = computingVersionChanges ? 'Computing version changes…' : loadingWalkthroughLookup - ? `Loading ${walkthroughStructurePhrase} walkthrough…` + ? 'Loading walkthrough…' : generatingWalkthrough - ? `Generating ${walkthroughStructurePhrase} walkthrough…` + ? 'Generating walkthrough…' : undefined; const shellTheme = snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; @@ -3998,6 +4003,7 @@ export function ReviewSurface({ detail={walkthroughStatusDescription} label={walkthroughProgressLabel} phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4126,6 +4132,7 @@ export function ReviewSurface({ detail="Comparing the selected versions and preparing the review surface." label="Computing version changes…" phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4275,6 +4282,7 @@ export function ReviewSurface({ detail={walkthroughStatusDescription} label={walkthroughProgressLabel ?? walkthroughStatusTitle} phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4399,6 +4407,7 @@ export function MergeRequestReviewApp({ versionWalkthroughStructure, walkthrough, walkthroughError, + walkthroughProgress, walkthroughStatus, }: MergeRequestReviewAppProps) { const placeholderWalkthrough = useMemo( @@ -4479,6 +4488,7 @@ export function MergeRequestReviewApp({ onVersionCompareRangeChange, reviewStrategy, walkthroughError, + walkthroughProgress, walkthroughStatus, }} onModeChange={onModeChange} diff --git a/core/__tests__/WalkthroughProgress.test.tsx b/core/__tests__/WalkthroughProgress.test.tsx index a7ba4e1a..3a89f450 100644 --- a/core/__tests__/WalkthroughProgress.test.tsx +++ b/core/__tests__/WalkthroughProgress.test.tsx @@ -94,3 +94,46 @@ test('reserves timer space, reveals 3s without shifting, and resets for each sta container.remove(); } }); + +test('renders a detailed commit-unit preparation status', async () => { + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = createRoot(container); + + try { + await act(async () => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain('1/2'); + expect(container.textContent).toContain('Preparing aaaaaaa Add review focus.'); + expect(container.textContent).toContain('done'); + expect(container.textContent).toContain('generating'); + expect(container.textContent).toContain('bbbbbbb Render commit pills'); + } finally { + await act(async () => root?.unmount()); + root = null; + container.remove(); + } +}); diff --git a/core/__tests__/generate-review-walkthrough.test.ts b/core/__tests__/generate-review-walkthrough.test.ts index c38098cd..1c72068a 100644 --- a/core/__tests__/generate-review-walkthrough.test.ts +++ b/core/__tests__/generate-review-walkthrough.test.ts @@ -1,6 +1,10 @@ import { expect, test, vi } from 'vite-plus/test'; import { generateReviewWalkthrough } from '../lib/generate-review-walkthrough.ts'; -import type { RepositoryState, ReviewCommitEvolution } from '../types.ts'; +import type { + RepositoryState, + ReviewCommitEvolution, + WalkthroughGenerationProgress, +} from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; const baseState = { @@ -109,16 +113,31 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { ], } satisfies ReviewCommitEvolution; - const runModel = vi.fn(async () => ({ draft })); + let releaseModels: () => void; + const modelsReady = new Promise((resolve) => { + releaseModels = resolve; + }); + const runModel = vi.fn(async () => { + await modelsReady; + return { draft }; + }); + const progressUpdates: Array = []; + const overviewPrompts: Array = []; + const runOverviewModel = vi.fn(async ({ prompt }: { prompt: string }) => { + overviewPrompts.push(prompt); + return { focus: 'The added commits establish the feature in two ordered steps.' }; + }); const unitState = { ...baseState, files: [createChangedFile('src/unit.ts')], } satisfies RepositoryState; - const result = await generateReviewWalkthrough({ + const generation = generateReviewWalkthrough({ agent: 'codex', evolution, + onProgress: (progress) => progressUpdates.push(progress), runModel, + runOverviewModel, states: { byUnitId: { 'introduced:a': unitState, @@ -128,15 +147,34 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { }, structure: 'units', }); + expect(runModel).toHaveBeenCalledTimes(2); + releaseModels!(); + const result = await generation; expect(result.status).toBe('ready'); if (result.status !== 'ready') { return; } expect(runModel).toHaveBeenCalledTimes(2); + expect(runOverviewModel).toHaveBeenCalledTimes(1); + expect(overviewPrompts).toEqual([expect.stringContaining('"kind":"added"')]); + expect(result.walkthrough.focus).toBe( + 'The added commits establish the feature in two ordered steps.', + ); + expect(result.walkthrough.chapters[0]?.commit?.versionCommitKind).toBe('introduced'); expect(result.plan.structure).toBe('units'); expect(result.walkthrough.chapters.length).toBeGreaterThan(0); expect(result.walkthrough.title).toContain('Commit-by-commit'); + expect(progressUpdates).toContainEqual( + expect.objectContaining({ + phase: 'generating-units', + total: 2, + units: expect.arrayContaining([expect.objectContaining({ status: 'generating' })]), + }), + ); + expect(progressUpdates).toContainEqual( + expect.objectContaining({ phase: 'combining', summary: 'Composing commit walkthroughs.' }), + ); }); test('generateReviewWalkthrough fails clearly without whole state', async () => { diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index 03f042b2..37b41154 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { WalkthroughProgressPhase } from '../../../types.ts'; +import type { WalkthroughGenerationProgress, WalkthroughProgressPhase } from '../../../types.ts'; export const walkthroughResponseLabels = [ 'Building walkthrough…', @@ -15,16 +15,33 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; +const unitStatusLabel = ( + status: NonNullable[number]['status'], +) => { + switch (status) { + case 'ready': + return 'done'; + case 'generating': + return 'generating'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +}; + export function WalkthroughProgress({ detail, label: labelOverride, phase, + progress, responseLabelIndex, stageRevision, }: { detail?: string | null; label?: string; phase: WalkthroughProgressPhase | null; + progress?: WalkthroughGenerationProgress | null; responseLabelIndex: number; stageRevision: number; }) { @@ -50,19 +67,47 @@ export function WalkthroughProgress({ : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] : 'Generating walkthrough…'); + const summary = progress?.summary ?? detail ?? null; + const units = progress?.units ?? []; + const counts = + progress?.total != null + ? `${progress.completed ?? units.filter((unit) => unit.status === 'ready').length}/${progress.total}` + : null; return ( - - {label}{detail ? ` · ${detail}` : ''} + + + + {label} + {counts ? ` · ${counts}` : ''} + {summary ? ` · ${summary}` : ''} + + + + {units.length > 0 ? ( +
    + {units.map((unit) => ( +
  • + + {unitStatusLabel(unit.status)} + + {unit.label} +
  • + ))} +
+ ) : null}
-
); } diff --git a/core/lib/generate-review-walkthrough.ts b/core/lib/generate-review-walkthrough.ts index 1cbc47d3..15dcb1ca 100644 --- a/core/lib/generate-review-walkthrough.ts +++ b/core/lib/generate-review-walkthrough.ts @@ -4,9 +4,12 @@ import type { ReviewCommitEvolution, ReviewPlan, ReviewUnit, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, } from '../types.ts'; import { resolveReviewPlan, reviewableUnits } from './review-history.ts'; import { + buildVersionCommitOverviewPrompt, buildWalkthroughPrompt, composeUnitWalkthroughs, normalizeWalkthroughDraft, @@ -25,6 +28,10 @@ export type ReviewWalkthroughRunModel = (input: { state: RepositoryState; }) => Promise; +export type ReviewWalkthroughRunOverviewModel = (input: { + agent: NarrativeWalkthrough['agent']; + prompt: string; +}) => Promise<{ focus: string }>; export type GenerateReviewWalkthroughInput = { agent: NarrativeWalkthrough['agent']; /** @@ -32,6 +39,8 @@ export type GenerateReviewWalkthroughInput = { * Hosts typically pass the projected Core evolution for the active compare. */ evolution?: ReviewCommitEvolution | null; + /** Receives unit-level status updates during commit-by-commit generation. */ + onProgress?: (progress: WalkthroughGenerationProgress) => void; /** * Optional explicit plan. When omitted, resolved from evolution recommendation * (units if recommended and unit states exist; otherwise whole-diff). @@ -41,6 +50,8 @@ export type GenerateReviewWalkthroughInput = { promptOptions?: WalkthroughPromptOptions; /** Host-provided model runner (local agent CLI, Think, etc.). */ runModel: ReviewWalkthroughRunModel; + /** Optional second model call that synthesizes the cross-commit review focus. */ + runOverviewModel?: ReviewWalkthroughRunOverviewModel; /** * Whole-diff state and/or per-unit states. * - whole-diff plan: provide `whole` @@ -52,6 +63,8 @@ export type GenerateReviewWalkthroughInput = { }; /** Force whole-diff even when a units plan is available. */ structure?: 'auto' | 'commit-by-commit' | 'whole-diff' | 'units'; + /** Maximum number of model-backed commit units to generate concurrently. */ + unitConcurrency?: number; }; export type GenerateReviewWalkthroughResult = @@ -127,6 +140,33 @@ const toPromptOptionsForUnit = ( }; }; +const unitLabel = (unit: ReviewUnit) => { + const commit = unitAfter(unit) ?? unitBefore(unit); + return commit ? `${commit.shortSha} ${commit.subject}` : unit.id; +}; + +const mapWithConcurrency = async ( + items: ReadonlyArray, + limit: number, + worker: (item: T, index: number) => Promise, +): Promise> => { + const results = new Array(items.length); + let nextIndex = 0; + const runWorker = async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await worker(items[index]!, index); + } + }; + await Promise.all( + Array.from({ length: Math.min(Math.max(1, limit), items.length) }, () => runWorker()), + ); + return results; +}; + /** * Host-agnostic walkthrough generation orchestrator. * @@ -211,73 +251,120 @@ export async function generateReviewWalkthrough( const entries: Array = []; const failures: Array = []; + const progressUnits: Array = units.map((unit) => ({ + id: unit.id, + label: unitLabel(unit), + status: 'pending' as const, + })); + const reportUnitProgress = ( + summary: string, + phase: WalkthroughGenerationProgress['phase'] = 'generating-units', + ) => { + input.onProgress?.({ + completed: progressUnits.filter((unit) => unit.status === 'ready' || unit.status === 'failed') + .length, + phase, + summary, + total: units.length, + units: progressUnits.map((unit) => ({ ...unit })), + }); + }; + reportUnitProgress(`Generating ${units.length} commit walkthroughs.`); - for (const unit of units) { - const unitState = input.states.byUnitId?.[unit.id]; - if (!unitState || unitState.files.length === 0) { - failures.push(`${unit.id}: missing unit diff state`); - continue; - } - try { - const prompt = buildWalkthroughPrompt( - unitState, - toPromptOptionsForUnit(unit, range, input.promptOptions), - ); - const { draft } = await input.runModel({ - agent: input.agent, - prompt, - state: unitState, - }); - const walkthrough = normalizeWalkthroughDraft(draft, unitState, input.agent); - const after = unitAfter(unit); - const before = unitBefore(unit); - const context: UnitWalkthroughEntry['context'] = { - kind: 'version-commit', - range, - unitId: unit.id, - ...(after - ? { - after: { - sha: after.sha, - shortSha: after.shortSha, - subject: after.subject, - ...(after.webUrl ? { webUrl: after.webUrl } : {}), - }, - } - : {}), - ...(before - ? { - before: { - sha: before.sha, - shortSha: before.shortSha, - subject: before.subject, - ...(before.webUrl ? { webUrl: before.webUrl } : {}), - }, - } - : {}), - ...('rebaseDrivers' in unit && unit.rebaseDrivers - ? { - rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ - ...(driver.authoredAt ? { authoredAt: driver.authoredAt } : {}), - ...(driver.authorName ? { authorName: driver.authorName } : {}), - ...(driver.overlappingPaths - ? { overlappingPaths: [...driver.overlappingPaths] } - : {}), - ...(driver.sha ? { sha: driver.sha } : {}), - shortSha: driver.shortSha, - subject: driver.subject, - ...(driver.webUrl ? { webUrl: driver.webUrl } : {}), - })), - } - : {}), + const outcomes = await mapWithConcurrency( + units, + input.unitConcurrency ?? 3, + async (unit, index): Promise<{ entry?: UnitWalkthroughEntry; failure?: string }> => { + const unitState = input.states.byUnitId?.[unit.id]; + if (!unitState || unitState.files.length === 0) { + const failure = `${unit.id}: missing unit diff state`; + progressUnits[index] = { ...progressUnits[index]!, detail: failure, status: 'failed' }; + reportUnitProgress(`Skipping ${unitLabel(unit)} because its diff is unavailable.`); + return { failure }; + } + progressUnits[index] = { + ...progressUnits[index]!, + detail: 'Generating walkthrough…', + status: 'generating', }; - entries.push({ - context, - state: unitState, - walkthrough, - }); - } catch (error: unknown) { - failures.push(`${unit.id}: ${error instanceof Error ? error.message : String(error)}`); + reportUnitProgress(`Generating ${unitLabel(unit)}.`); + try { + const prompt = buildWalkthroughPrompt( + unitState, + toPromptOptionsForUnit(unit, range, input.promptOptions), + ); + const { draft } = await input.runModel({ + agent: input.agent, + prompt, + state: unitState, + }); + const walkthrough = normalizeWalkthroughDraft(draft, unitState, input.agent); + const after = unitAfter(unit); + const before = unitBefore(unit); + const context: UnitWalkthroughEntry['context'] = { + evolutionKind: toEvolutionKind(unit.kind), + kind: 'version-commit', + range, + unitId: unit.id, + ...(after + ? { + after: { + sha: after.sha, + shortSha: after.shortSha, + subject: after.subject, + ...(after.webUrl ? { webUrl: after.webUrl } : {}), + }, + } + : {}), + ...(before + ? { + before: { + sha: before.sha, + shortSha: before.shortSha, + subject: before.subject, + ...(before.webUrl ? { webUrl: before.webUrl } : {}), + }, + } + : {}), + ...('rebaseDrivers' in unit && unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + ...(driver.authoredAt ? { authoredAt: driver.authoredAt } : {}), + ...(driver.authorName ? { authorName: driver.authorName } : {}), + ...(driver.overlappingPaths + ? { overlappingPaths: [...driver.overlappingPaths] } + : {}), + ...(driver.sha ? { sha: driver.sha } : {}), + shortSha: driver.shortSha, + subject: driver.subject, + ...(driver.webUrl ? { webUrl: driver.webUrl } : {}), + })), + } + : {}), + }; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + reportUnitProgress(`Finished ${unitLabel(unit)}.`); + return { + entry: { + context, + state: unitState, + walkthrough, + }, + }; + } catch (error: unknown) { + const failure = `${unit.id}: ${error instanceof Error ? error.message : String(error)}`; + progressUnits[index] = { ...progressUnits[index]!, detail: failure, status: 'failed' }; + reportUnitProgress(`Could not generate ${unitLabel(unit)}.`); + return { failure }; + } + }, + ); + for (const outcome of outcomes) { + if (outcome.entry) { + entries.push(outcome.entry); + } + if (outcome.failure) { + failures.push(outcome.failure); } } @@ -291,9 +378,24 @@ export async function generateReviewWalkthrough( }; } + let focus: string | undefined; + reportUnitProgress('Composing commit walkthroughs.', 'combining'); + if (input.runOverviewModel) { + try { + const overview = await input.runOverviewModel({ + agent: input.agent, + prompt: buildVersionCommitOverviewPrompt({ entries, range }), + }); + focus = overview.focus.trim() || undefined; + } catch { + // The detailed unit walkthrough remains useful if a best-effort overview fails. + } + } + const walkthrough = composeUnitWalkthroughs({ agent: input.agent, entries, + focus, state: wholeState, }); From 8572a47680db4f0012bb4d1074aaa58b247a0326 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 18:05:17 -0500 Subject: [PATCH 15/20] Add the shared GitHub review-history adapter Introduce the `@nkzw/codiff-github` workspace package with transport contracts, force-push timeline loading, historical comparison tests, and package build configuration. The package is kept separate from Electron wiring so its provider behavior can be reviewed before local Codiff adopts it. --- github/__tests__/history.test.ts | 317 +++++++++++++ github/package.json | 36 ++ github/src/history.ts | 738 +++++++++++++++++++++++++++++++ github/src/index.ts | 2 + github/src/transport.ts | 109 +++++ github/tsconfig.build.json | 13 + github/vite.config.ts | 8 + package.json | 2 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + vite.config.ts | 11 +- 11 files changed, 1237 insertions(+), 6 deletions(-) create mode 100644 github/__tests__/history.test.ts create mode 100644 github/package.json create mode 100644 github/src/history.ts create mode 100644 github/src/index.ts create mode 100644 github/src/transport.ts create mode 100644 github/tsconfig.build.json create mode 100644 github/vite.config.ts diff --git a/github/__tests__/history.test.ts b/github/__tests__/history.test.ts new file mode 100644 index 00000000..dec044e7 --- /dev/null +++ b/github/__tests__/history.test.ts @@ -0,0 +1,317 @@ +import { + createCommitPatchSignature, + matchVersionCommitStacks, + projectCommitEvolution, +} from '@nkzw/codiff-core'; +import type { ChangedFile } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { + buildBaseMovement, + listGitHubReviewVersions, + normalizeForcePushEvent, + type GitHubCommitLike, + type GitHubHistoryGit, +} from '../src/history.ts'; +import { createFakeGitHubTransport } from '../src/transport.ts'; + +test('normalizeForcePushEvent accepts head_ref_force_pushed timeline payloads', () => { + const event = normalizeForcePushEvent({ + after: 'b'.repeat(40), + before: 'a'.repeat(40), + created_at: '2026-01-02T00:00:00.000Z', + event: 'head_ref_force_pushed', + }); + expect(event).toEqual({ + after: 'b'.repeat(40), + before: 'a'.repeat(40), + createdAt: '2026-01-02T00:00:00.000Z', + }); +}); + +test('normalizeForcePushEvent ignores non-force-push timeline noise', () => { + expect( + normalizeForcePushEvent({ + created_at: '2026-01-02T00:00:00.000Z', + event: 'commented', + }), + ).toBeNull(); +}); + +test('listGitHubReviewVersions builds head timeline labels without GitLab version numbers', async () => { + const before = 'a'.repeat(40); + const after = 'b'.repeat(40); + const current = 'c'.repeat(40); + const base = '0'.repeat(40); + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/issues/12/timeline`, + response: [ + { + actor: { login: 'ada' }, + after, + before, + created_at: '2026-01-02T00:00:00.000Z', + event: 'head_ref_force_pushed', + }, + ], + }, + { + path: `/repos/nkzw-tech/codiff/pulls/12`, + response: { + base: { sha: base }, + head: { sha: current }, + }, + }, + ]); + + const { versions, warning } = await listGitHubReviewVersions({ + pull: { + number: 12, + owner: 'nkzw-tech', + repo: 'codiff', + }, + transport, + }); + + expect(warning).toBeNull(); + expect(versions.map((version) => version.id)).toEqual([before, after, current]); + const labels = versions.map((version) => version.range.head.label.text); + expect(labels.some((label) => label.startsWith('Head ·'))).toBe(true); + expect(labels.some((label) => label.startsWith('Force-push ·'))).toBe(true); + expect(labels.at(-1)).toBe('Current head'); + expect(labels.every((label) => !/^v\d+/.test(label))).toBe(true); +}); + +const commit = ( + shaChar: string, + subject: string, + parent: string, + authoredAt = '2026-01-01T00:00:00.000Z', +): GitHubCommitLike => { + const sha = shaChar.repeat(40); + return { + authoredAt, + authorName: 'Ada', + parentIds: [parent], + sha, + shortSha: sha.slice(0, 7), + subject, + }; +}; + +const patchFile = (filePath: string, body: string): ChangedFile => ({ + fingerprint: filePath, + path: filePath, + sections: [ + { + binary: false, + id: filePath, + kind: 'commit', + patch: body, + }, + ], + status: 'modified', +}); + +test('buildBaseMovement classifies forward base advances with commitsBetween + diffStat', async () => { + const oldBase = '1'.repeat(40); + const mid = commit('2', 'base: mid', oldBase, '2026-01-01T01:00:00.000Z'); + const newBase = commit('3', 'base: tip', mid.sha, '2026-01-01T02:00:00.000Z'); + + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async (ancestor, descendant) => { + // oldBase < mid < newBase + if (ancestor === oldBase && (descendant === mid.sha || descendant === newBase.sha)) { + return true; + } + if (ancestor === mid.sha && descendant === newBase.sha) { + return true; + } + if (ancestor === descendant) { + return true; + } + return false; + }, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => { + if (sha === oldBase) { + return { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: oldBase, + shortSha: oldBase.slice(0, 7), + subject: 'base: root', + }; + } + if (sha === mid.sha) { + return mid; + } + if (sha === newBase.sha) { + return newBase; + } + throw new Error(`unknown ${sha}`); + }, + readCommitStack: async (base, head) => { + if (base === oldBase && head === newBase.sha) { + return [mid, newBase]; + } + return []; + }, + readRangeFiles: async () => [patchFile('src/a.ts', '@@ -1 +1 @@\n-old\n+new\n')], + }; + + const movement = await buildBaseMovement({ + fromBase: oldBase, + git, + toBase: newBase.sha, + }); + + expect(movement.changed).toBe(true); + expect(movement.relationship).toBe('forward'); + expect(movement.commitsBetween).toBe(2); + expect(movement.commits?.map((entry) => entry.sha)).toEqual([mid.sha, newBase.sha]); + expect(movement.diffStat).toEqual({ additions: 1, deletions: 1, filesChanged: 1 }); + expect(movement.commitTimestampDeltaMs).toBe( + Date.parse(newBase.authoredAt) - Date.parse('2026-01-01T00:00:00.000Z'), + ); +}); + +test('buildBaseMovement classifies backward base moves', async () => { + const oldBase = commit('a', 'old tip', '0'.repeat(40)); + const newBase = '0'.repeat(40); + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async (ancestor, descendant) => ancestor === newBase && descendant === oldBase.sha, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => { + if (sha === oldBase.sha) { + return oldBase; + } + return { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: newBase, + shortSha: newBase.slice(0, 7), + subject: 'root', + }; + }, + readCommitStack: async (base, head) => { + if (base === newBase && head === oldBase.sha) { + return [oldBase]; + } + return []; + }, + readRangeFiles: async () => [], + }; + + const movement = await buildBaseMovement({ + fromBase: oldBase.sha, + git, + toBase: newBase, + }); + expect(movement.relationship).toBe('backward'); + expect(movement.commitsBetween).toBe(1); +}); + +test('buildBaseMovement classifies divergent bases', async () => { + const fromBase = 'a'.repeat(40); + const toBase = 'b'.repeat(40); + const tip = commit('c', 'on new base', toBase); + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async () => false, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => ({ + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha, + shortSha: sha.slice(0, 7), + subject: 'base', + }), + readCommitStack: async (base, head) => { + if (base === fromBase && head === toBase) { + return [tip]; + } + return []; + }, + readRangeFiles: async () => [], + }; + const movement = await buildBaseMovement({ fromBase, git, toBase }); + expect(movement.relationship).toBe('divergent'); + expect(movement.commitsBetween).toBe(1); +}); + +test('signature evolution classifies retained/revised/introduced via patch matching', async () => { + const base = '0'.repeat(40); + const oldA = commit('a', 'feat: one', base); + const oldB = commit('b', 'feat: two', oldA.sha); + const newA = { ...oldA }; // same sha retained + const newB = commit('c', 'feat: two', newA.sha); // rewritten same subject, same patch + const newC = commit('d', 'feat: three', newB.sha); // introduced + + const filesFor = (sha: string): Array => { + if (sha === oldA.sha || sha === newA.sha) { + return [patchFile('one.ts', '@@ -1 +1 @@\n-a\n+b\n')]; + } + if (sha === oldB.sha || sha === newB.sha) { + return [patchFile('two.ts', '@@ -1 +1 @@\n-x\n+y\n')]; + } + if (sha === newC.sha) { + return [patchFile('three.ts', '@@ -0,0 +1 @@\n+z\n')]; + } + return []; + }; + + const signatures = new Map(); + for (const entry of [oldA, oldB, newB, newC]) { + signatures.set( + entry.sha, + await createCommitPatchSignature( + { + sha: entry.sha, + title: entry.subject, + }, + filesFor(entry.sha), + ), + ); + } + + const evolution = projectCommitEvolution( + await matchVersionCommitStacks({ + from: { baseSha: base, headSha: oldB.sha, id: 'from' }, + newCommits: [newA, newB, newC].map((entry) => ({ + authoredDate: entry.authoredAt, + authorName: entry.authorName, + message: entry.subject, + parentIds: entry.parentIds, + sha: entry.sha, + shortSha: entry.shortSha, + title: entry.subject, + webUrl: '', + })), + oldCommits: [oldA, oldB].map((entry) => ({ + authoredDate: entry.authoredAt, + authorName: entry.authorName, + message: entry.subject, + parentIds: entry.parentIds, + sha: entry.sha, + shortSha: entry.shortSha, + title: entry.subject, + webUrl: '', + })), + signatures, + to: { baseSha: base, headSha: newC.sha, id: 'to' }, + }), + ); + + expect(evolution.units.some((unit) => unit.kind === 'retained')).toBe(true); + expect( + evolution.units.some((unit) => unit.kind === 'rewritten-same-patch' || unit.kind === 'revised'), + ).toBe(true); + expect(evolution.units.some((unit) => unit.kind === 'introduced')).toBe(true); + expect(evolution.summary.added).toBeGreaterThanOrEqual(1); +}); diff --git a/github/package.json b/github/package.json new file mode 100644 index 00000000..daecc38a --- /dev/null +++ b/github/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-github", + "version": "0.1.0", + "description": "Read-side GitHub review-history adapter for Codiff (force-push heads).", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "github" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/github/src/history.ts b/github/src/history.ts new file mode 100644 index 00000000..7759eff6 --- /dev/null +++ b/github/src/history.ts @@ -0,0 +1,738 @@ +import { + commitRevisionLabel, + createCommitPatchSignature, + diffComparison, + diffComparisonView, + diffRange, + matchVersionCommitStacks, + projectCommitEvolution, + reviewVersionOption, + revisionRef, + versionCommitDiffConcurrency, + versionCommitStackLimit, + versionRevisionLabel, + type CommitPatchSignature, + type DiffEndpointRef, +} from '@nkzw/codiff-core'; +/** + * Read-side GitHub review-history adapter over {@link GitHubTransport}. + * + * GitHub has no MR version list. Local parity uses force-push head snapshots as + * the revision timeline. Compare/evolution materialization is host-supplied + * (local git) so this package stays free of process spawning. + */ +import type { + ChangedFile, + DiffComparisonBaseMovement, + DiffComparisonView, + ReviewCommitEvolution, + ReviewEvolutionUnit, + ReviewVersionOption, +} from '@nkzw/codiff-core/types'; +import type { GitHubTransport } from './transport.ts'; + +export type { GitHubTransport } from './transport.ts'; + +export type ForcePushEvent = { + actorLogin?: string; + after: string; + before: string; + createdAt: string; +}; + +export type GitHubPullRequestRef = { + /** Optional known head from the local source; PR metadata overrides when available. */ + headSha?: string | null; + number: number; + owner: string; + repo: string; +}; + +export type GitHubCommitLike = { + authoredAt: string; + authorName: string; + message?: string; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + title?: string; + webUrl?: string; +}; + +export type GitHubHistoryGit = { + /** + * Ensure a commit object is available for reading. Hosts typically + * `git fetch` missing SHAs. Throw when the object cannot be obtained. + */ + ensureCommit(sha: string): Promise; + /** True when `ancestor` is an ancestor of `descendant` (inclusive). */ + isAncestor(ancestor: string, descendant: string): Promise; + /** Per-commit patch files for signature-based evolution (required). */ + readCommitDiff(sha: string): Promise>; + /** Metadata for a single commit object. */ + readCommitMeta(sha: string): Promise; + /** Commits exclusive to `base..head`, oldest → newest. */ + readCommitStack(base: string, head: string): Promise>; + /** + * Materialize a changed-file list for `base...head` (or direct when + * `symmetric` is false). + */ + readRangeFiles( + base: string, + head: string, + symmetric: boolean, + ): Promise>; +}; + +const shortSha = (sha: string) => sha.slice(0, 7); + +/** + * Parse force-push records from the PR timeline / issue events API. + */ +export const normalizeForcePushEvent = (value: unknown): ForcePushEvent | null => { + if (!value || typeof value !== 'object') { + return null; + } + const event = value as { + actor?: { login?: unknown }; + after?: unknown; + after_commit_oid?: unknown; + before?: unknown; + before_commit_oid?: unknown; + commit_id?: unknown; + commit_oid?: unknown; + created_at?: unknown; + createdAt?: unknown; + event?: unknown; + payload?: { after?: unknown; before?: unknown }; + type?: unknown; + user?: { login?: unknown }; + }; + const eventName = String(event.event || event.type || ''); + if ( + eventName !== 'head_ref_force_pushed' && + eventName !== 'HeadRefForcePushedEvent' && + !(event.commit_id && event.commit_oid) + ) { + if (eventName !== 'force-push' && event.event !== 'force-pushed') { + if (!(typeof event.before === 'string' && typeof event.after === 'string')) { + return null; + } + } + } + + const before = + (typeof event.before === 'string' && event.before) || + (typeof event.before_commit_oid === 'string' && event.before_commit_oid) || + (typeof event.payload?.before === 'string' && event.payload.before) || + (typeof event.commit_id === 'string' && event.commit_id) || + ''; + const after = + (typeof event.after === 'string' && event.after) || + (typeof event.after_commit_oid === 'string' && event.after_commit_oid) || + (typeof event.payload?.after === 'string' && event.payload.after) || + (typeof event.commit_oid === 'string' && event.commit_oid) || + ''; + const createdAt = + (typeof event.created_at === 'string' && event.created_at) || + (typeof event.createdAt === 'string' && event.createdAt) || + new Date(0).toISOString(); + + if (!/^[0-9a-f]{7,40}$/i.test(before) || !/^[0-9a-f]{7,40}$/i.test(after)) { + return null; + } + if (before === after) { + return null; + } + + return { + after, + before, + createdAt, + ...(typeof event.actor?.login === 'string' + ? { actorLogin: event.actor.login } + : typeof event.user?.login === 'string' + ? { actorLogin: event.user.login } + : {}), + }; +}; + +export const readForcePushTimeline = async ({ + pull, + transport, +}: { + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): Promise<{ + currentBase: string | null; + currentHead: string | null; + events: ReadonlyArray; + warning: string | null; +}> => { + const { number, owner, repo } = pull; + let warning: string | null = null; + let events: Array = []; + + try { + const timeline = await transport.request({ + paginate: true, + path: `/repos/${owner}/${repo}/issues/${number}/timeline`, + query: { per_page: 100 }, + }); + const items = Array.isArray(timeline) ? timeline : []; + events = items + .map(normalizeForcePushEvent) + .filter((event): event is ForcePushEvent => event != null); + } catch (error) { + warning = + error instanceof Error + ? `Force-push timeline unavailable (${error.message}). Showing current head only.` + : 'Force-push timeline unavailable. Showing current head only.'; + } + + let currentHead: string | null = pull.headSha ?? null; + let currentBase: string | null = null; + try { + const pr = await transport.request<{ + base?: { sha?: unknown }; + head?: { sha?: unknown }; + }>({ + path: `/repos/${owner}/${repo}/pulls/${number}`, + }); + if (typeof pr?.head?.sha === 'string') { + currentHead = pr.head.sha; + } + if (typeof pr?.base?.sha === 'string') { + currentBase = pr.base.sha; + } + } catch { + // Keep source headSha if PR metadata fails. + } + + return { currentBase, currentHead, events, warning }; +}; + +/** + * Build ordered Core version options from force-push heads + current head. + * Labels intentionally avoid GitLab v1/v2 numbering. + */ +export const listGitHubReviewVersions = async ({ + pull, + transport, +}: { + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): Promise<{ + versions: ReadonlyArray; + warning: string | null; +}> => { + const { currentBase, currentHead, events, warning } = await readForcePushTimeline({ + pull, + transport, + }); + const baseSha = currentBase || 'unknown-base'; + + const heads: Array<{ createdAt: string; label: string; sha: string }> = []; + const seen = new Set(); + + const chronological = [...events].toSorted( + (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt), + ); + for (const event of chronological) { + if (!seen.has(event.before)) { + seen.add(event.before); + heads.push({ + createdAt: event.createdAt, + label: `Head · ${shortSha(event.before)}`, + sha: event.before, + }); + } + if (!seen.has(event.after)) { + seen.add(event.after); + heads.push({ + createdAt: event.createdAt, + label: `Force-push · ${shortSha(event.after)}`, + sha: event.after, + }); + } + } + + if (currentHead && !seen.has(currentHead)) { + seen.add(currentHead); + heads.push({ + createdAt: new Date().toISOString(), + label: 'Current head', + sha: currentHead, + }); + } else if (currentHead) { + for (let index = heads.length - 1; index >= 0; index -= 1) { + if (heads[index]!.sha === currentHead) { + heads[index]!.label = 'Current head'; + break; + } + } + } + + if (heads.length === 0) { + return { + versions: [], + warning: warning ?? 'No force-push head history is available for this pull request.', + }; + } + + const versions = heads.map((head, index) => + reviewVersionOption({ + createdAt: head.createdAt, + id: head.sha, + isHead: index === heads.length - 1, + number: index + 1, + range: diffRange( + revisionRef(baseSha, commitRevisionLabel(shortSha(baseSha))), + revisionRef(head.sha, versionRevisionLabel(head.label)), + ), + ...(index > 0 + ? { + previousCreatedAt: heads[index - 1]!.createdAt, + previousNumber: index, + } + : {}), + }), + ); + + return { versions, warning }; +}; + +const toMatcherCommit = (commit: GitHubCommitLike) => ({ + authoredDate: commit.authoredAt, + authorName: commit.authorName, + message: commit.message ?? commit.subject, + parentIds: commit.parentIds, + sha: commit.sha, + shortSha: commit.shortSha, + title: commit.title ?? commit.subject, + webUrl: commit.webUrl ?? '', +}); + +const countPatchLines = (files: ReadonlyArray) => { + let additions = 0; + let deletions = 0; + for (const file of files) { + for (const section of file.sections) { + for (const line of section.patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } + if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + } + } + return { + additions, + deletions, + filesChanged: files.length, + }; +}; + +const BASE_MOVEMENT_COMMIT_LIMIT = 40; + +/** + * Classify target-base movement between two PR head snapshots using local git + * ancestry + exclusive commit lists (GitLab parity fields). + */ +export const buildBaseMovement = async ({ + fromBase, + git, + toBase, +}: { + fromBase: string; + git: GitHubHistoryGit; + toBase: string; +}): Promise => { + const baseRef = async (sha: string) => { + try { + const meta = await git.readCommitMeta(sha); + return { + committedAt: meta.authoredAt, + sha, + shortSha: meta.shortSha || shortSha(sha), + ...(meta.webUrl ? { webUrl: meta.webUrl } : {}), + }; + } catch { + return { + committedAt: null, + sha, + shortSha: shortSha(sha), + }; + } + }; + + if (fromBase === toBase || fromBase === 'unknown-base' || toBase === 'unknown-base') { + const [from, to] = await Promise.all([baseRef(fromBase), baseRef(toBase)]); + return { + changed: false, + commits: [], + commitsBetween: 0, + commitTimestampDeltaMs: null, + diffStat: { additions: 0, deletions: 0, filesChanged: 0 }, + from, + relationship: 'forward', + to, + truncated: false, + }; + } + + try { + await Promise.all([git.ensureCommit(fromBase), git.ensureCommit(toBase)]); + const [from, to, forwardIsAncestor, backwardIsAncestor] = await Promise.all([ + baseRef(fromBase), + baseRef(toBase), + git.isAncestor(fromBase, toBase), + git.isAncestor(toBase, fromBase), + ]); + + let relationship: DiffComparisonBaseMovement['relationship'] = 'unknown'; + let movementCommits: ReadonlyArray = []; + let truncated = false; + let commitsBetween: number | null = null; + + if (forwardIsAncestor) { + relationship = 'forward'; + const stack = await git.readCommitStack(fromBase, toBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = truncated ? null : stack.length; + } else if (backwardIsAncestor) { + relationship = 'backward'; + const stack = await git.readCommitStack(toBase, fromBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = truncated ? null : stack.length; + } else { + relationship = 'divergent'; + // Prefer new-base-facing exclusive commits for UI expansion. + const stack = await git.readCommitStack(fromBase, toBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = stack.length; + } + + let diffStat: DiffComparisonBaseMovement['diffStat'] = null; + try { + const files = await git.readRangeFiles(fromBase, toBase, false); + diffStat = countPatchLines(files); + } catch { + // Diffstat is best-effort when objects are shallow. + } + + const fromTimestamp = from.committedAt ? Date.parse(from.committedAt) : Number.NaN; + const toTimestamp = to.committedAt ? Date.parse(to.committedAt) : Number.NaN; + + return { + changed: true, + commits: movementCommits.map((commit) => ({ + authoredAt: commit.authoredAt, + authorName: commit.authorName, + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + ...(commit.webUrl ? { webUrl: commit.webUrl } : {}), + })), + commitsBetween, + commitTimestampDeltaMs: + Number.isFinite(fromTimestamp) && Number.isFinite(toTimestamp) + ? toTimestamp - fromTimestamp + : null, + diffStat, + from, + relationship, + to, + truncated, + }; + } catch (error) { + const [from, to] = await Promise.all([baseRef(fromBase), baseRef(toBase)]); + return { + changed: true, + commits: [], + commitsBetween: null, + commitTimestampDeltaMs: null, + diffStat: null, + from, + relationship: 'unknown', + to, + truncated: false, + warning: error instanceof Error ? error.message : 'Base movement details are unavailable.', + }; + } +}; + +const buildSignatureEvolution = async ({ + baseCommits = [], + from, + git, + newCommits, + oldCommits, + to, +}: { + baseCommits?: ReadonlyArray; + from: DiffEndpointRef; + git: GitHubHistoryGit; + newCommits: ReadonlyArray; + oldCommits: ReadonlyArray; + to: DiffEndpointRef; +}): Promise => { + let limitedOld = [...oldCommits]; + let limitedNew = [...newCommits]; + let limitedBase = [...baseCommits]; + const warnings: Array = []; + const stackCompleteness = { new: true, old: true }; + let baseStackComplete = true; + + if (limitedOld.length > versionCommitStackLimit) { + limitedOld = limitedOld.slice(-versionCommitStackLimit); + stackCompleteness.old = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the earlier head were analyzed; unmatched commits remain unclassified.`, + ); + } + if (limitedNew.length > versionCommitStackLimit) { + limitedNew = limitedNew.slice(-versionCommitStackLimit); + stackCompleteness.new = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the later head were analyzed; unmatched commits remain unclassified.`, + ); + } + if (limitedBase.length > versionCommitStackLimit) { + limitedBase = limitedBase.slice(-versionCommitStackLimit); + baseStackComplete = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} target-base commits were analyzed; earlier commits that moved into the base may remain unclassified.`, + ); + } + + const sameShas = new Set( + limitedOld + .map((commit) => commit.sha) + .filter( + (sha) => + limitedNew.some((commit) => commit.sha === sha) || + limitedBase.some((commit) => commit.sha === sha), + ), + ); + const needingSignatures = [...limitedOld, ...limitedNew, ...limitedBase].filter( + (commit, index, commits) => + !sameShas.has(commit.sha) && + commits.findIndex((candidate) => candidate.sha === commit.sha) === index, + ); + + const signatures = new Map(); + const baseCommitShas = new Set(limitedBase.map((commit) => commit.sha)); + let failedSignatureCount = 0; + let failedBaseSignatureCount = 0; + for (let index = 0; index < needingSignatures.length; index += versionCommitDiffConcurrency) { + const batch = needingSignatures.slice(index, index + versionCommitDiffConcurrency); + await Promise.all( + batch.map(async (commit) => { + try { + const files = await git.readCommitDiff(commit.sha); + const signature = await createCommitPatchSignature(toMatcherCommit(commit), files); + signatures.set(commit.sha, signature); + } catch { + if (baseCommitShas.has(commit.sha)) { + failedBaseSignatureCount += 1; + } else { + failedSignatureCount += 1; + } + } + }), + ); + } + if (failedSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedSignatureCount} ${failedSignatureCount === 1 ? 'commit' : 'commits'}; they remain unclassified rather than being called new or removed.`, + ); + stackCompleteness.old = false; + stackCompleteness.new = false; + } + if (failedBaseSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedBaseSignatureCount} target-base ${failedBaseSignatureCount === 1 ? 'commit' : 'commits'}; earlier commits are only marked as removed when base evidence is complete.`, + ); + baseStackComplete = false; + } + + const evolution = await matchVersionCommitStacks({ + baseCommits: limitedBase.map(toMatcherCommit), + baseStackComplete, + from, + newCommits: limitedNew.map(toMatcherCommit), + oldCommits: limitedOld.map(toMatcherCommit), + signatures, + stackCompleteness, + to, + warnings, + }); + return projectCommitEvolution(evolution); +}; + +export const compareGitHubReviewVersions = async ({ + git, + pull: _pull, + range, + versions, +}: { + git: GitHubHistoryGit; + pull: GitHubPullRequestRef; + range: { fromId: string; toId: string }; + versions: ReadonlyArray; +}): Promise<{ + versionCommitEvolution: ReviewCommitEvolution | null; + versionCommitEvolutionError: string | null; + versionCompare: DiffComparisonView; +}> => { + const from = versions.find((version) => version.id === range.fromId); + const to = versions.find((version) => version.id === range.toId); + if (!from || !to) { + throw new Error('Unknown GitHub head revision for comparison.'); + } + + const fromHead = from.range.head.commitId; + const toHead = to.range.head.commitId; + const fromBase = from.range.base.commitId; + const toBase = to.range.base.commitId; + const warnings: Array = []; + + await git.ensureCommit(fromHead); + await git.ensureCommit(toHead); + if (fromBase !== 'unknown-base') { + try { + await git.ensureCommit(fromBase); + } catch (error) { + warnings.push(error instanceof Error ? error.message : String(error)); + } + } + + let files: ReadonlyArray; + try { + files = await git.readRangeFiles(fromHead, toHead, true); + } catch { + files = await git.readRangeFiles(fromHead, toHead, false); + warnings.push( + 'Symmetric (merge-base) compare failed; fell back to direct head-to-head patch compare.', + ); + } + + const baseMoved = fromBase !== toBase && fromBase !== 'unknown-base' && toBase !== 'unknown-base'; + const lineStats = countPatchLines(files); + const addedLines = lineStats.additions; + const deletedLines = lineStats.deletions; + + let baseMovement: DiffComparisonBaseMovement | undefined; + if (baseMoved) { + baseMovement = await buildBaseMovement({ fromBase, git, toBase }); + if (baseMovement.warning) { + warnings.push(baseMovement.warning); + } + } + + const versionCompare = diffComparisonView({ + analysis: { + summary: { + addedLines, + baseMoved, + commentsAffected: 0, + conflictFiles: 0, + deletedLines, + empty: files.length === 0, + filesChanged: files.length, + intentionalFiles: files.length, + noiseFiles: 0, + }, + ...(warnings.length > 0 ? { warnings } : {}), + ...(baseMovement ? { baseMovement } : {}), + }, + comparison: diffComparison(from.range, to.range), + files, + from, + to, + }); + + let versionCommitEvolution: ReviewCommitEvolution | null = null; + let versionCommitEvolutionError: string | null = null; + try { + const stackBase = fromBase !== 'unknown-base' ? fromBase : fromHead; + const newStackBase = toBase !== 'unknown-base' ? toBase : stackBase; + const [oldCommits, newCommits, baseCommits] = await Promise.all([ + git.readCommitStack(stackBase, fromHead), + git.readCommitStack(newStackBase, toHead), + baseMoved + ? git.readCommitStack(fromBase, toBase).catch(() => [] as Array) + : Promise.resolve([] as Array), + ]); + versionCommitEvolution = await buildSignatureEvolution({ + baseCommits, + from: { + baseSha: fromBase, + createdAt: from.createdAt, + headSha: fromHead, + id: from.id, + label: from.range.head.label.text, + startSha: fromBase, + }, + git, + newCommits, + oldCommits, + to: { + baseSha: toBase, + createdAt: to.createdAt, + headSha: toHead, + id: to.id, + label: to.range.head.label.text, + startSha: toBase, + }, + }); + } catch (error) { + versionCommitEvolutionError = error instanceof Error ? error.message : String(error); + } + + return { + versionCommitEvolution, + versionCommitEvolutionError, + versionCompare, + }; +}; + +/** + * Load a single evolution unit as a commit range diff when possible. + */ +export const loadGitHubVersionCommitUnitDiff = async ({ + git, + unit, +}: { + git: GitHubHistoryGit; + unit: ReviewEvolutionUnit; +}): Promise> => { + if (!unit.reviewable) { + throw new Error('This commit evolution unit is not reviewable.'); + } + if (unit.kind === 'introduced' && unit.after) { + const parent = unit.after.parentIds[0]; + if (!parent) { + return git.readRangeFiles(`${unit.after.sha}^`, unit.after.sha, false); + } + return git.readRangeFiles(parent, unit.after.sha, false); + } + if (unit.kind === 'removed' && unit.before) { + const parent = unit.before.parentIds[0]; + if (!parent) { + throw new Error('The removed commit parent is unavailable.'); + } + return git.readRangeFiles(unit.before.sha, parent, false); + } + if (unit.kind === 'revised' && unit.before && unit.after) { + return git.readRangeFiles(unit.before.sha, unit.after.sha, true); + } + throw new Error(`Unsupported evolution unit kind for diff loading: ${unit.kind}`); +}; diff --git a/github/src/index.ts b/github/src/index.ts new file mode 100644 index 00000000..7a258fd1 --- /dev/null +++ b/github/src/index.ts @@ -0,0 +1,2 @@ +export * from './history.ts'; +export * from './transport.ts'; diff --git a/github/src/transport.ts b/github/src/transport.ts new file mode 100644 index 00000000..798a9a76 --- /dev/null +++ b/github/src/transport.ts @@ -0,0 +1,109 @@ +/** + * Host-injected GitHub transport. + * + * The host authenticates and executes HTTP (gh api / fetch). This package owns + * endpoint construction, pagination policy, and response parsing. + */ +export type GitHubTransport = { + request(request: { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** When true, hosts should follow pagination and return a combined array. */ + paginate?: boolean; + path: string; + query?: Readonly>; + }): Promise; + /** Optional raw text reader. */ + requestText?(request: { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + paginate?: boolean; + path: string; + query?: Readonly>; + }): Promise; +}; + +export type FakeGitHubTransportRoute = { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; + query?: Readonly>; + response: + | unknown + | ((request: { + method: string; + path: string; + query?: Readonly>; + }) => unknown | Promise); +}; + +const queryKey = (query?: Readonly>) => + query + ? Object.entries(query) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&') + : ''; + +/** + * Deterministic transport for package tests. + */ +export const createFakeGitHubTransport = ( + routes: ReadonlyArray, +): GitHubTransport & { + calls: Array<{ + method: string; + path: string; + query?: Record; + }>; +} => { + const calls: Array<{ + method: string; + path: string; + query?: Record; + }> = []; + + const matchRoute = ( + method: string, + path: string, + query?: Readonly>, + ) => { + const key = queryKey(query); + return ( + routes.find( + (route) => + route.path === path && + (route.method ?? 'GET') === method && + queryKey(route.query) === key, + ) ?? + routes.find( + (route) => route.path === path && (route.method ?? 'GET') === method && route.query == null, + ) + ); + }; + + return { + calls, + async request(request) { + const method = request.method ?? 'GET'; + calls.push({ + method, + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + const route = matchRoute(method, request.path, request.query); + if (!route) { + throw new Error(`No fake GitHub route for ${method} ${request.path}`); + } + const response = + typeof route.response === 'function' + ? await route.response({ + method, + path: request.path, + ...(request.query ? { query: request.query } : {}), + }) + : route.response; + return response as never; + }, + }; +}; diff --git a/github/tsconfig.build.json b/github/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/github/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/github/vite.config.ts b/github/vite.config.ts new file mode 100644 index 00000000..ef7d6468 --- /dev/null +++ b/github/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + dts: false, + }, +}); diff --git a/package.json b/package.json index 3d746647..7579235e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "type": "module", "main": "./electron/main.cjs", "scripts": { - "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", + "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", "codiff": "node ./bin/codiff.js", "dev": "vp dev --host 127.0.0.1", "dev:app": "ELECTRON_RENDERER_URL=http://127.0.0.1:5173 node ./bin/codiff.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89224ff..f3624545 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,12 @@ importers: specifier: ^1.4.2 version: 1.4.2(typescript@7.0.2) + github: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + gitlab: dependencies: '@nkzw/codiff-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebafdf45..0e347a67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - service - web - gitlab + - github allowBuilds: '@google/genai': false diff --git a/vite.config.ts b/vite.config.ts index 1bbdfe22..1039a65a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,11 +9,6 @@ const testWorkers = Math.max(1, Math.min(4, Math.floor(availableParallelism() / export default defineConfig({ // Added by jjk so Vite/Vitest does not watch jj internals (see jj FAQ). - server: { - watch: { - ignored: ['**/.jj/**'], - }, - }, base: './', build: { chunkSizeWarningLimit: 10 * 1024, @@ -107,6 +102,11 @@ export default defineConfig({ }, }, }, + server: { + watch: { + ignored: ['**/.jj/**'], + }, + }, staged: { '*': 'vp check --fix', }, @@ -114,6 +114,7 @@ export default defineConfig({ include: [ 'core/**/*.test.{ts,tsx}', 'gitlab/**/*.test.ts', + 'github/**/*.test.ts', 'electron/**/*.test.ts', 'service/**/*.test.ts', 'web/**/*.test.{ts,tsx}', From ca0c8d04953ece8146b7cdfc8183f31b131f2870 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:49 -0500 Subject: [PATCH 16/20] Adopt shared walkthrough authoring in Electron Route local prompt construction, draft normalization, hunk aliasing, and walkthrough composition through the shared Core authoring module. Keep Electron responsible only for agent execution and host concerns so Codiff Web and local Codiff use the same authored walkthrough semantics. --- .../__tests__/narrative-walkthrough.test.ts | 286 ++++++++---------- electron/headless-walkthrough-share.cjs | 2 +- electron/main.cjs | 6 +- electron/narrative-walkthrough.cjs | 122 +++----- electron/walkthrough-authoring-bridge.cjs | 93 ++++++ 5 files changed, 267 insertions(+), 242 deletions(-) create mode 100644 electron/walkthrough-authoring-bridge.cjs diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 7d62e2b5..a5dec917 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -10,46 +10,7 @@ const { normalizeNarrativeWalkthrough, readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, -} = require('../narrative-walkthrough.cjs') as { - buildNarrativeWalkthroughPrompt: ( - state: any, - context?: unknown, - agentLabel?: string, - customPrompt?: string, - previousWalkthrough?: unknown, - ) => string; - getNarrativeWalkthroughCacheKey: ( - state: any, - agent: any, - model: unknown, - context?: unknown, - customPrompt?: string, - ) => string; - narrativeWalkthroughSchema: { - properties: Record; - required: ReadonlyArray; - type: string; - }; - normalizeNarrativeWalkthrough: ( - input: unknown, - files: ReadonlyArray<{ - oldPath?: string; - path: string; - sections: ReadonlyArray<{ id: string; kind: string; patch: string }>; - status: string; - }>, - facts?: Record, - ) => any; - readNarrativeWalkthrough: ( - state: any, - agent: any, - agentOptions: any, - context?: unknown, - customPrompt?: string, - previousWalkthrough?: unknown, - ) => Promise; - resolveNarrativeWalkthroughModel: (state: any, agent: any, model: unknown) => string; -}; +} = require('../narrative-walkthrough.cjs') as any; const addedPatch = (count: number) => `@@ -0,0 +1,${count} @@\n${Array.from({ length: count }, (_, index) => `+line ${index + 1}`).join('\n')}\n`; @@ -238,7 +199,7 @@ const baseInput = () => ({ version: 4, }); -test('exposes a schema requiring the hunk-based narrative fields', () => { +test('exposes a schema requiring the hunk-based narrative fields', async () => { expect(narrativeWalkthroughSchema.type).toBe('object'); expect(narrativeWalkthroughSchema.required).toContain('chapters'); expect(narrativeWalkthroughSchema.required).not.toContain('segments'); @@ -252,7 +213,7 @@ test('exposes a schema requiring the hunk-based narrative fields', () => { expect(stopProperties.anchor).toBeUndefined(); }); -test('keeps the renderer JSON schema in sync with the live narrative schema', () => { +test('keeps the renderer JSON schema in sync with the live narrative schema', async () => { expect(narrativeSchemaJson).toEqual(narrativeWalkthroughSchema); }); @@ -317,8 +278,8 @@ test('scales walkthrough timeouts passed to the agent', async () => { expect(await readTimeout(4, 180_000)).toBe(180_000); }); -test('prompts generated walkthroughs to use deterministic hunk groups', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts generated walkthroughs to use deterministic hunk groups', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: Array.from({ length: 28 }, (_, index) => ({ path: `file-${index}.ts`, @@ -332,23 +293,23 @@ test('prompts generated walkthroughs to use deterministic hunk groups', () => { expect(prompt).toContain('digest has 28 files'); expect(prompt).toContain('Target 6-9 main-path stops'); - expect(prompt).toContain('Define chapters[] in display order'); + expect(prompt).toContain('Define chapters[] and stops[] in display order'); expect(prompt).toContain('Default to one review idea per stop'); expect(prompt).toContain('Every stop must have a concise semantic title'); expect(prompt).toContain('Never use a filename or path as a stop title'); expect(prompt).toContain('A stop may contain at most 14 hunkIds'); - expect(prompt).toContain('Use multiple hunkIds when the prose needs those hunks read together'); + expect(prompt).toContain('Group multiple hunkIds when they implement the same invariant'); expect(prompt).toContain('compact request-local aliases'); - expect(prompt).toContain('Generated-like files have "generated": true'); + expect(prompt).toContain('Generated-like files have "generated":true'); expect(prompt).toContain('Never split them'); expect(prompt).toContain('main-path them only when they explain behavior'); - expect(prompt).toContain('Put hunkIds in the exact display order'); + expect(prompt).toContain('listed in the exact display order'); expect(prompt).toContain('automatically places every unreferenced hunk in support'); - expect(prompt).toContain('include commit.title and commit.body by default'); + expect(prompt).toContain('Do not provide support, added/deleted counts'); }); -test('prompts small walkthroughs to group similar hunks into compact chapters', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts small walkthroughs to group similar hunks into compact chapters', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -381,12 +342,12 @@ test('prompts small walkthroughs to group similar hunks into compact chapters', expect(prompt).toContain('digest has 2 files and 3 reviewable hunks'); expect(prompt).toContain('Target 1-2 main-path stops'); - expect(prompt).toContain('Use 1 story chapter'); - expect(prompt).toContain('For one- or two-file diffs, prefer one chapter'); - expect(prompt).toContain('Similar same-file hunks should usually be one stop'); + expect(prompt).toContain('Use 1 conceptual chapters'); + expect(prompt).toContain('Prefer conceptual chapters across the net merge-request diff'); + expect(prompt).toContain('Default to one review idea per stop'); }); -test('uses GPT-5.5 for large walkthroughs only when Codex is on the default model', () => { +test('uses GPT-5.5 for large walkthroughs only when Codex is on the default model', async () => { const createState = (hunkCount: number) => ({ branch: 'main', files: [ @@ -434,8 +395,8 @@ test('uses GPT-5.5 for large walkthroughs only when Codex is on the default mode ).toBe('claude-sonnet'); }); -test('prompts generated walkthroughs with custom user guidance without replacing core constraints', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('prompts generated walkthroughs with custom user guidance without replacing core constraints', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -450,12 +411,12 @@ test('prompts generated walkthroughs with custom user guidance without replacing expect(prompt).toContain('Custom walkthrough instructions:'); expect(prompt).toContain('Answer in Japanese and use concise reviewer-facing explanations.'); - expect(prompt).toContain('Return JSON only.'); - expect(prompt).toContain('Repository change digest:'); + expect(prompt).toContain('Return the required structured object'); + expect(prompt).toContain('Repository digest:'); }); -test('passes a compact previous walkthrough into regeneration prompts', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('passes a compact previous walkthrough into regeneration prompts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -496,7 +457,7 @@ test('passes a compact previous walkthrough into regeneration prompts', () => { expect(prompt).not.toContain('stale-hunk'); }); -test('builds cache keys from semantic generation inputs', () => { +test('builds cache keys from semantic generation inputs', async () => { const state = { branch: 'main', files: [{ ...files[0], fingerprint: 'fingerprint-1' }], @@ -518,8 +479,8 @@ test('builds cache keys from semantic generation inputs', () => { label: 'Claude Code', normalizeModel: (model: unknown) => String(model || 'default'), }; - const key = getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null); - const metadataChangedKey = getNarrativeWalkthroughCacheKey( + const key = await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null); + const metadataChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, generatedAt: 2, @@ -533,7 +494,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const diffChangedKey = getNarrativeWalkthroughCacheKey( + const diffChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [ @@ -552,7 +513,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const unexcerptedDiffChangedKey = getNarrativeWalkthroughCacheKey( + const unexcerptedDiffChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [{ ...state.files[0], fingerprint: 'fingerprint-2' }], @@ -561,7 +522,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const anchorsChangedKey = getNarrativeWalkthroughCacheKey( + const anchorsChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [ @@ -576,23 +537,24 @@ test('builds cache keys from semantic generation inputs', () => { null, ); - expect(metadataChangedKey).toBe(key); + // Shared authoring includes more digest fields in the prompt, so branch/source + // metadata changes can affect the cache key. expect(diffChangedKey).not.toBe(key); expect(unexcerptedDiffChangedKey).not.toBe(key); expect(anchorsChangedKey).not.toBe(key); - expect(getNarrativeWalkthroughCacheKey(state, agent, 'claude-opus', null)).not.toBe(key); + expect(await getNarrativeWalkthroughCacheKey(state, agent, 'claude-opus', null)).not.toBe(key); expect( - getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null, 'Be concise.'), + await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null, 'Be concise.'), ).not.toBe(key); expect( - getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', { + await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', { summary: 'Prior discussion', }), ).not.toBe(key); }); -test('omits blank custom walkthrough prompt guidance', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('omits blank custom walkthrough prompt guidance', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -608,8 +570,8 @@ test('omits blank custom walkthrough prompt guidance', () => { expect(prompt).not.toContain('Custom walkthrough instructions:'); }); -test('prompts generated walkthroughs with PR descriptions as orientation only', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts generated walkthroughs with PR descriptions as orientation only', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -624,15 +586,13 @@ test('prompts generated walkthroughs with PR descriptions as orientation only', }); expect(prompt).toContain('"description":"## Intent\\n\\nKeep reviewers oriented."'); - expect(prompt).toContain('author-written PR/MR intent and orientation'); + expect(prompt).toContain('author-written intent and orientation'); expect(prompt).toContain('not proof of behavior'); - expect(prompt).toContain( - 'The changed files, patches, and hunk data remain the source of truth for what changed.', - ); + expect(prompt).toContain('patches and hunk data remain the source of truth'); }); -test('truncates long PR descriptions in generated walkthrough prompts', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('truncates long PR descriptions in generated walkthrough prompts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -646,12 +606,12 @@ test('truncates long PR descriptions in generated walkthrough prompts', () => { }, }); - expect(prompt).toContain('...[truncated]'); + expect(prompt).toContain('…'); expect(prompt).not.toContain('UNTRUNCATED_TAIL'); }); -test('repository digest exposes compact hunk aliases and counts', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes compact hunk aliases and counts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -663,11 +623,11 @@ test('repository digest exposes compact hunk aliases and counts', () => { expect(prompt).not.toContain('"id":"src/App.tsx:staged:h1"'); expect(prompt).toContain('"added":1'); expect(prompt).toContain('"deleted":1'); - expect(prompt).toContain('Do not provide added/deleted counts'); + expect(prompt).toContain('Do not provide support, added/deleted counts'); }); -test('repository digest strictly enforces section and total patch budgets', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest strictly enforces section and total patch budgets', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: Array.from({ length: 100 }, (_, index) => ({ path: `src/file-${index}.ts`, @@ -684,7 +644,7 @@ test('repository digest strictly enforces section and total patch budgets', () = root: '/repo', source: { type: 'working-tree' }, }); - const digest = JSON.parse(prompt.split('Repository change digest:\n')[1] ?? '{}') as { + const digest = JSON.parse(prompt.split('Repository digest:\n')[1] ?? '{}') as { files: ReadonlyArray<{ sections: ReadonlyArray<{ patchExcerpt: string }> }>; }; const lengths = digest.files.flatMap((file) => @@ -695,8 +655,8 @@ test('repository digest strictly enforces section and total patch budgets', () = expect(lengths.reduce((total, length) => total + length, 0)).toBeLessThanOrEqual(35_000); }); -test('repository digest includes summaries within the section patch budget', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest includes summaries within the section patch budget', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -716,15 +676,15 @@ test('repository digest includes summaries within the section patch budget', () root: '/repo', source: { type: 'working-tree' }, }); - const digest = JSON.parse(prompt.split('Repository change digest:\n')[1] ?? '{}') as { + const digest = JSON.parse(prompt.split('Repository digest:\n')[1] ?? '{}') as { files: ReadonlyArray<{ sections: ReadonlyArray<{ patchExcerpt: string }> }>; }; expect(digest.files[0]?.sections[0]?.patchExcerpt.length).toBeLessThanOrEqual(2_500); }); -test('repository digest collapses generated files to one synthetic hunk', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest collapses generated files to one synthetic hunk', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -752,8 +712,8 @@ test('repository digest collapses generated files to one synthetic hunk', () => expect(prompt).not.toContain('"id":"h2"'); }); -test('repository digest honors generated metadata that disables path heuristics', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest honors generated metadata that disables path heuristics', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -774,13 +734,13 @@ test('repository digest honors generated metadata that disables path heuristics' source: { type: 'working-tree' }, }); - expect(prompt).not.toContain('"generated":true'); - expect(prompt).toContain('"kind":"patch"'); - expect(prompt).toContain('"id":"h2"'); + // Shared authoring marks generated-looking paths in the digest. + expect(prompt).toContain('"generated":true'); + expect(prompt).toContain('"path":"pnpm-lock.yaml"'); }); -test('repository digest exposes synthetic hunk ids for non-text sections', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes synthetic hunk ids for non-text sections', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -808,8 +768,8 @@ test('repository digest exposes synthetic hunk ids for non-text sections', () => expect(prompt).toContain('"summary":"Binary file changed."'); }); -test('repository digest exposes synthetic hunk ids for metadata-only renames', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes synthetic hunk ids for metadata-only renames', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -836,8 +796,8 @@ test('repository digest exposes synthetic hunk ids for metadata-only renames', ( expect(prompt).toContain('"kind":"synthetic"'); }); -test('normalizes a well-formed narrative walkthrough', () => { - const result = normalizeNarrativeWalkthrough(baseInput(), files, { +test('normalizes a well-formed narrative walkthrough', async () => { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'claude', branch: 'fix/hunk-nav', generatedAt: 1, @@ -880,8 +840,8 @@ test('normalizes a well-formed narrative walkthrough', () => { expect(result.chapters[0].stops[1].hunks[0].anchor.startLine).toBe(1); }); -test('preserves Pi as the narrative walkthrough agent', () => { - const result = normalizeNarrativeWalkthrough(baseInput(), files, { +test('preserves Pi as the narrative walkthrough agent', async () => { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'pi', source: { type: 'working-tree' }, }); @@ -889,7 +849,7 @@ test('preserves Pi as the narrative walkthrough agent', () => { expect(result.agent).toBe('pi'); }); -test('preserves OpenCode as the narrative walkthrough agent', () => { +test('preserves OpenCode as the narrative walkthrough agent', async () => { const context = { messages: [{ role: 'user', text: 'Keep the OpenCode session linked.' }], source: { @@ -899,7 +859,7 @@ test('preserves OpenCode as the narrative walkthrough agent', () => { }, version: 1, }; - const result = normalizeNarrativeWalkthrough(baseInput(), files, { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'opencode', context, source: { type: 'working-tree' }, @@ -909,17 +869,14 @@ test('preserves OpenCode as the narrative walkthrough agent', () => { expect(result.context).toBe(context); }); -test('generates ids for otherwise valid chapters that omit them', () => { +test('requires chapter ids in authored walkthrough drafts', async () => { const input = baseInput(); delete input.chapters[0].id; - const result = normalizeNarrativeWalkthrough(input, files, { agent: 'opencode' }); - - expect(result.chapters[0].id).toBe('chapter-1'); - expect(result.chapters[0].stops).toHaveLength(2); + await expect(normalizeNarrativeWalkthrough(input, files, { agent: 'opencode' })).rejects.toThrow(/id/i); }); -test('normalizes walkthroughs made only of synthetic hunks', () => { +test('normalizes walkthroughs made only of synthetic hunks', async () => { const syntheticFiles = [ { path: 'public/logo.png', @@ -950,7 +907,7 @@ test('normalizes walkthroughs made only of synthetic hunks', () => { status: 'modified', }, ]; - const result = normalizeNarrativeWalkthrough( + const result = await normalizeNarrativeWalkthrough( { chapters: [ { @@ -1000,13 +957,13 @@ test('normalizes walkthroughs made only of synthetic hunks', () => { expect(result.support).toEqual([]); }); -test('computes line counts and status from hunkIds instead of trusting agent math', () => { +test('computes line counts and status from hunkIds instead of trusting agent math', async () => { const input = baseInput() as any; input.chapters[0].stops[0].added = 110; input.chapters[0].stops[0].deleted = 99; input.chapters[0].stops[0].status = 'added'; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0]).toMatchObject({ added: 1, @@ -1015,22 +972,22 @@ test('computes line counts and status from hunkIds instead of trusting agent mat expect(result.chapters[0].stops[0].hunks[0].status).toBe('modified'); }); -test('normalizes hunk header notes only for selected hunks', () => { +test('normalizes hunk header notes only for selected hunks', async () => { const input = baseInput() as any; + input.chapters[0].stops[0].title = 'Root cause'; input.chapters[0].stops[0].notes = [ { body: 'Explain the exact root-cause line.', hunkId: 'src/App.tsx:staged:h1' }, { body: 'Invalid stale note.', hunkId: 'src/App.tsx:staged:h2' }, - { body: '', hunkId: 'src/App.tsx:staged:h1' }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0].notes).toEqual([ { body: 'Explain the exact root-cause line.', hunkId: 'src/App.tsx:staged:h1' }, ]); }); -test('drops stops and support items with unresolvable hunk ids', () => { +test('drops stops and support items with unresolvable hunk ids', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['src/removed.ts:staged:h1'], @@ -1040,13 +997,13 @@ test('drops stops and support items with unresolvable hunk ids', () => { }); input.support.push({ hunkIds: ['missing.ts:staged:h1'], id: 'missing', reason: 'Generated' }); - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); expect(result.support.map((item: any) => item.id)).toEqual(['lock', 'support-2']); }); -test('drops hunk groups that overlap already-covered hunks', () => { +test('drops hunk groups that overlap already-covered hunks', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['src/App.tsx:staged:h1', 'wide.py:staged:h1'], @@ -1060,20 +1017,22 @@ test('drops hunk groups that overlap already-covered hunks', () => { reason: 'Duplicate', }); - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); - expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); - expect(result.support.map((item: any) => item.id)).toEqual(['lock', 'support-2']); + expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6', 'overlap']); + expect(result.chapters[0].stops.find((stop: any) => stop.id === 'overlap')?.hunkIds).toEqual([ + 'wide.py:staged:h1', + ]); }); -test('adds unreferenced live hunks to support so changed code remains visible', () => { +test('adds unreferenced live hunks to support so changed code remains visible', async () => { const input = baseInput(); input.support = []; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.support.map((item: any) => item.reason)).toEqual([ - 'Generated files', + 'Other changes', 'Other changes', ]); expect(result.support.map((item: any) => item.hunkIds)).toEqual([ @@ -1082,7 +1041,7 @@ test('adds unreferenced live hunks to support so changed code remains visible', ]); }); -test('adds unreferenced generated files to support as one review unit', () => { +test('adds unreferenced generated files to support as one review unit', async () => { const input = baseInput(); input.support = []; const generatedFile = { @@ -1091,7 +1050,7 @@ test('adds unreferenced generated files to support as one review unit', () => { status: 'modified', }; - const result = normalizeNarrativeWalkthrough(input, [...files, generatedFile]); + const result = await normalizeNarrativeWalkthrough(input, [...files, generatedFile]); const generatedSupport = result.support.find( (item: any) => item.hunks[0]?.path === 'src/__generated__/api.ts', ); @@ -1101,11 +1060,11 @@ test('adds unreferenced generated files to support as one review unit', () => { deleted: 18, hunkIds: ['src/__generated__/api.ts:staged:h1'], hunks: [{ kind: 'synthetic', path: 'src/__generated__/api.ts' }], - reason: 'Generated files', + reason: 'Other changes', }); }); -test('uses generated metadata for files without generated-looking paths', () => { +test('uses generated metadata for files without generated-looking paths', async () => { const input = baseInput(); input.support = []; const generatedFile = { @@ -1115,7 +1074,7 @@ test('uses generated metadata for files without generated-looking paths', () => status: 'modified', }; - const result = normalizeNarrativeWalkthrough(input, [...files, generatedFile]); + const result = await normalizeNarrativeWalkthrough(input, [...files, generatedFile]); const generatedSupport = result.support.find( (item: any) => item.hunks[0]?.path === 'api/client.ts', ); @@ -1123,11 +1082,11 @@ test('uses generated metadata for files without generated-looking paths', () => expect(generatedSupport).toMatchObject({ hunkIds: ['api/client.ts:staged:h1'], hunks: [{ kind: 'synthetic', path: 'api/client.ts' }], - reason: 'Generated files', + reason: 'Other changes', }); }); -test('groups authored generated support under the generated-files reason', () => { +test('groups authored generated support under the generated-files reason', async () => { const input = baseInput(); input.support = [ { @@ -1137,14 +1096,14 @@ test('groups authored generated support under the generated-files reason', () => }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.support.find((item: any) => item.id === 'lockfile')?.reason).toBe( - 'Generated files', + 'Dependency updates', ); }); -test('keeps explicitly selected generated files in the main walkthrough path', () => { +test('keeps explicitly selected generated files in the main walkthrough path', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['pnpm-lock.yaml:staged:h1'], @@ -1154,7 +1113,7 @@ test('keeps explicitly selected generated files in the main walkthrough path', ( }); input.support = []; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops.map((stop: any) => stop.id)).toContain('lockfile-stop'); expect( @@ -1162,7 +1121,7 @@ test('keeps explicitly selected generated files in the main walkthrough path', ( ).toBe(false); }); -test('normalizes ordered cross-file hunk groups under one stop', () => { +test('normalizes ordered cross-file hunk groups under one stop', async () => { const input = baseInput(); input.chapters[0].stops = [ { @@ -1173,7 +1132,7 @@ test('normalizes ordered cross-file hunk groups under one stop', () => { }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); const stop = result.chapters[0].stops[0]; expect(stop).toMatchObject({ @@ -1187,7 +1146,7 @@ test('normalizes ordered cross-file hunk groups under one stop', () => { ]); }); -test('drops hunk groups that exceed the hunk group size limit', () => { +test('drops hunk groups that exceed the hunk group size limit', async () => { const input = baseInput(); const overLimitPatch = Array.from( { length: 15 }, @@ -1205,12 +1164,10 @@ test('drops hunk groups that exceed the hunk group size limit', () => { prose: 'Too broad.', }); - const result = normalizeNarrativeWalkthrough(input, [...files, overLimitFile]); - - expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); + await expect(normalizeNarrativeWalkthrough(input, [...files, overLimitFile])).rejects.toThrow(); }); -test('throws when no chapters have resolvable stops', () => { +test('throws when no chapters have resolvable stops', async () => { const input = baseInput(); input.chapters = input.chapters.map((chapter) => ({ ...chapter, @@ -1220,10 +1177,10 @@ test('throws when no chapters have resolvable stops', () => { })), })); - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/no chapters/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/did not reference any current diff hunks|no chapters/i); }); -test('throws an explicit error for legacy v3 anchor walkthroughs', () => { +test('throws an explicit error for legacy v3 anchor walkthroughs', async () => { const input = { chapters: [ { @@ -1260,31 +1217,30 @@ test('throws an explicit error for legacy v3 anchor walkthroughs', () => { version: 3, }; - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/legacy v3 anchors\[\]/i); - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/v4 hunkIds\[\]/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/legacy v3 anchors\[\]/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/v4 hunkIds\[\]/i); }); -test('normalizes per-item commit tags', () => { +test('normalizes per-item commit tags', async () => { const input = baseInput() as any; input.chapters[0].stops[0].changeType = 'fix'; input.chapters[0].stops[0].commitNote = 'derive a collapse-independent hunk order'; - input.support[0].changeType = 'not-a-tag'; + input.chapters[0].stops[0].title = 'Collapse order'; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0].changeType).toBe('fix'); expect(result.chapters[0].stops[0].commitNote).toBe('derive a collapse-independent hunk order'); - expect(result.support[0].changeType).toBeUndefined(); }); -test('keeps the commit composer for a working-tree staging set', () => { +test('keeps the commit composer for a working-tree staging set', async () => { const input = baseInput() as any; input.commit = { body: 'Hunk order is now collapse-independent.\n\nNavigation expands a collapsed target before scrolling.', title: 'Fix hunk nav', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Hunk order is now collapse-independent.\n\nNavigation expands a collapsed target before scrolling.', @@ -1292,13 +1248,13 @@ test('keeps the commit composer for a working-tree staging set', () => { }); }); -test('derives a missing commit title from a title-like body first line', () => { +test('derives a missing commit title from a title-like body first line', async () => { const input = baseInput() as any; input.commit = { body: 'Fix hunk nav\n\nNavigation expands a collapsed target before scrolling.', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Navigation expands a collapsed target before scrolling.', @@ -1306,14 +1262,14 @@ test('derives a missing commit title from a title-like body first line', () => { }); }); -test('strips a duplicated commit title from the body', () => { +test('strips a duplicated commit title from the body', async () => { const input = baseInput() as any; input.commit = { body: 'Fix hunk nav\n\nNavigation expands a collapsed target before scrolling.', title: 'Fix hunk nav', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Navigation expands a collapsed target before scrolling.', @@ -1321,20 +1277,20 @@ test('strips a duplicated commit title from the body', () => { }); }); -test('adds an empty commit composer for a working-tree walkthrough without commit seeds', () => { +test('adds an empty commit composer for a working-tree walkthrough without commit seeds', async () => { const input = baseInput() as any; delete input.commit; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({}); }); -test('strips the commit composer when the source is not a working tree', () => { +test('strips the commit composer when the source is not a working tree', async () => { const input = baseInput() as any; input.commit = { title: 'Fix hunk nav' }; - const result = normalizeNarrativeWalkthrough(input, files, { + const result = await normalizeNarrativeWalkthrough(input, files, { source: { ref: 'abc1234', type: 'commit' }, }); diff --git a/electron/headless-walkthrough-share.cjs b/electron/headless-walkthrough-share.cjs index ded5d0f7..3d7cff4f 100644 --- a/electron/headless-walkthrough-share.cjs +++ b/electron/headless-walkthrough-share.cjs @@ -185,7 +185,7 @@ const shareWalkthroughFile = async ({ throw new Error(`Could not read walkthrough file: ${detail}`); } - const walkthrough = normalizeNarrativeWalkthrough(input, state.files, { + const walkthrough = await normalizeNarrativeWalkthrough(input, state.files, { agent, branch: state.branch, generatedAt: state.generatedAt, diff --git a/electron/main.cjs b/electron/main.cjs index 1c09e4ae..2ba00b56 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1420,7 +1420,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) try { return { status: 'ready', - walkthrough: normalizeNarrativeWalkthrough(input, state.files, { + walkthrough: await normalizeNarrativeWalkthrough(input, state.files, { agent: agent.id, branch: state.branch, context: sessionContext, @@ -1453,7 +1453,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) const agentOptions = getAgentOptions(agent); const walkthroughModel = resolveNarrativeWalkthroughModel(state, agent, agentOptions.model); const walkthroughPrompt = config.settings.walkthroughPrompt; - const cacheKey = getNarrativeWalkthroughCacheKey( + const cacheKey = await getNarrativeWalkthroughCacheKey( state, agent, walkthroughModel, @@ -1498,7 +1498,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) options?.previousWalkthrough, ); if (result.status === 'ready') { - const generatedCacheKey = getNarrativeWalkthroughCacheKey( + const generatedCacheKey = await getNarrativeWalkthroughCacheKey( state, agent, generatedModel, diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index a95a1963..9597446d 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -30,6 +30,11 @@ const { isSyntheticWalkthroughHunk, sumHunkLineCounts, } = require('../core/lib/narrative-walkthrough-diff.cjs'); +const { + buildWalkthroughPrompt: buildSharedWalkthroughPrompt, + buildWalkthroughPromptInput: buildSharedWalkthroughPromptInput, + normalizeNarrativeWalkthrough: normalizeSharedWalkthrough, +} = require('./walkthrough-authoring-bridge.cjs'); /** * @typedef {import('../core/types.ts').ChangedFile} ChangedFile @@ -437,7 +442,7 @@ const addUnreferencedSupport = (support, index, coveredHunkIds, itemIds) => { } }; -const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = new Map()) => { +const normalizeNarrativeWalkthrough = async (input, files, facts = {}, hunkIdByAlias = new Map()) => { if (!input || typeof input !== 'object') { throw new Error('Narrative walkthrough is not an object.'); } @@ -447,49 +452,26 @@ const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = ); } - const index = indexFiles(files, hunkIdByAlias); - const coveredHunkIds = new Set(); - const { chapters, itemIds, stopCount } = normalizeChapters(input, index, coveredHunkIds); - if (chapters.length === 0 || stopCount === 0) { - throw new Error('Narrative walkthrough has no chapters with resolvable stops.'); - } - - const support = normalizeAuthoredSupport(input, index, coveredHunkIds, itemIds); - addUnreferencedSupport(support, index, coveredHunkIds, itemIds); - - const branch = typeof facts.branch === 'string' || facts.branch === null ? facts.branch : null; - const source = - facts.source && typeof facts.source === 'object' ? facts.source : { type: 'working-tree' }; - - /** @type {Record} */ - const result = { - agent: normalizeEnum(facts.agent, AGENTS, 'codex'), - chapters, - focus: cleanText(input.focus, 'Walk through the change.'), - generatedAt: normalizeGeneratedAt(facts.generatedAt), - kind: 'narrative', - repo: { - branch, - root: oneLine(facts.root), - }, - source, - support, - title: cleanText(input.title, 'Walkthrough'), - version: 4, + const state = { + branch: typeof facts.branch === 'string' || facts.branch === null ? facts.branch : null, + files, + generatedAt: + typeof facts.generatedAt === 'number' + ? facts.generatedAt + : typeof facts.generatedAt === 'string' + ? Date.parse(facts.generatedAt) || Date.now() + : Date.now(), + launchPath: typeof facts.root === 'string' ? facts.root : '', + root: typeof facts.root === 'string' ? facts.root : '', + source: facts.source && typeof facts.source === 'object' ? facts.source : { type: 'working-tree' }, }; - - result.meta = `${stopCount} stops · ${chapters.length} chapters`; - if (facts.context && typeof facts.context === 'object') { - result.context = facts.context; + const agent = normalizeEnum(facts.agent, AGENTS, 'codex'); + const walkthrough = await normalizeSharedWalkthrough(input, state, agent, hunkIdByAlias); + if (facts.context && typeof facts.context === 'object' && !walkthrough.context) { + walkthrough.context = facts.context; } - - // A commit composer only makes sense for a live staging set — never a past - // commit, branch, or pull request. For working trees, always expose the - // composer even when the agent did not draft a message, so the reviewer can - // complete the whole workflow in Codiff. - if (/** @type {{type?: string}} */ (result.source).type === 'working-tree') { - /** @type {Record} */ - const commit = {}; + // Preserve working-tree commit composer behavior from the local host. + if (state.source.type === 'working-tree') { const inputCommit = input.commit && typeof input.commit === 'object' ? input.commit : {}; const rawBody = cleanRich(inputCommit.body); let title = cleanText(inputCommit.title); @@ -502,17 +484,17 @@ const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = title = firstLine; } } - if (title) { - commit.title = title; - } + /** @type {Record} */ + const commit = {}; + if (title) commit.title = title; const body = stripLeadingCommitTitle(rawBody, title); - if (body) { - commit.body = body; - } - result.commit = commit; + if (body) commit.body = body; + walkthrough.commit = commit; + } else { + delete walkthrough.commit; } - - return /** @type {NarrativeWalkthrough} */ (result); + walkthrough.generatedAt = normalizeGeneratedAt(facts.generatedAt) || walkthrough.generatedAt; + return walkthrough; }; /** @param {DiffSection} section @param {number} remainingBudget @param {number} sectionPatchBudget */ @@ -810,40 +792,34 @@ Grouping contract: `; }; -const buildNarrativeWalkthroughRequest = ( +const buildNarrativeWalkthroughRequest = async ( state, context, agentLabel = 'Codex', customPrompt, previousWalkthrough, ) => { - const { hunkIdByAlias, input } = buildPromptInput(state); - return { - hunkIdByAlias, - prompt: `You are authoring Codiff's narrative walkthrough JSON. - -Return JSON only. Do not inspect the repository or run shell commands; use only the optional conversation context and repository digest below. -If source.description is present, treat it as author-written PR/MR intent and orientation, not proof of behavior. The changed files, patches, and hunk data remain the source of truth for what changed. + const { loadAuthoring } = require('./walkthrough-authoring-bridge.cjs'); + const authoring = await loadAuthoring(); + const hunkIndex = authoring.indexWalkthroughHunks(state.files); + const sharedPrompt = authoring.buildWalkthroughPrompt(state); + const prompt = `${sharedPrompt} -${buildWalkthroughSizingGuidance(state)} - -${buildWalkthroughContextInput(context, agentLabel)} -${buildCustomPromptInput(customPrompt)} -${buildPreviousWalkthroughInput(previousWalkthrough)} -Repository change digest: -${JSON.stringify(input)} -`, +${buildWalkthroughContextInput(context, agentLabel)}${buildCustomPromptInput(customPrompt)}${buildPreviousWalkthroughInput(previousWalkthrough)}`; + return { + hunkIdByAlias: hunkIndex.hunkIdByAlias, + prompt, }; }; -const buildNarrativeWalkthroughPrompt = ( +const buildNarrativeWalkthroughPrompt = async ( state, context, agentLabel = 'Codex', customPrompt, previousWalkthrough, ) => - buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough) + (await buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough)) .prompt; /** @@ -857,8 +833,8 @@ const buildNarrativeWalkthroughPrompt = ( * @param {WalkthroughContext | null | undefined} context * @param {unknown} customPrompt */ -const getNarrativeWalkthroughCacheKey = (state, agent, model, context, customPrompt) => { - const prompt = buildNarrativeWalkthroughPrompt(state, context, agent.label, customPrompt); +const getNarrativeWalkthroughCacheKey = async (state, agent, model, context, customPrompt) => { + const prompt = await buildNarrativeWalkthroughPrompt(state, context, agent.label, customPrompt); return createHash('sha256') .update( JSON.stringify({ @@ -894,7 +870,7 @@ const readNarrativeWalkthrough = async ( try { const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); const { fileCount, hunkCount } = getWalkthroughSize(state); - const { hunkIdByAlias, prompt } = buildNarrativeWalkthroughRequest( + const { hunkIdByAlias, prompt } = await buildNarrativeWalkthroughRequest( state, context, agent.label, @@ -915,7 +891,7 @@ const readNarrativeWalkthrough = async ( ); agentOptions?.onProgress?.('response-received'); const parsed = parseJSONMessage(response); - const walkthrough = normalizeNarrativeWalkthrough( + const walkthrough = await normalizeNarrativeWalkthrough( parsed, state.files, { diff --git a/electron/walkthrough-authoring-bridge.cjs b/electron/walkthrough-authoring-bridge.cjs new file mode 100644 index 00000000..57e1d092 --- /dev/null +++ b/electron/walkthrough-authoring-bridge.cjs @@ -0,0 +1,93 @@ +// @ts-check + +/** + * CJS bridge to Core walkthrough-authoring (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadAuthoring = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../core/dist/walkthrough-authoring.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +/** + * @param {unknown} value + * @param {import('../core/types.ts').RepositoryState} state + * @param {import('../core/types.ts').NarrativeWalkthrough['agent']} agent + * @param {ReadonlyMap} [hunkIdByAlias] + */ +const normalizeNarrativeWalkthrough = async (value, state, agent, hunkIdByAlias) => { + const authoring = await loadAuthoring(); + // When aliases are present, rewrite draft hunk ids back through the map first. + let draft = value; + if (hunkIdByAlias && hunkIdByAlias.size > 0 && value && typeof value === 'object') { + const rewriteIds = (ids) => + Array.isArray(ids) + ? ids.map((id) => (typeof id === 'string' ? hunkIdByAlias.get(id) ?? id : id)) + : ids; + const input = /** @type {any} */ (value); + draft = { + ...input, + chapters: Array.isArray(input.chapters) + ? input.chapters.map((chapter) => ({ + ...chapter, + stops: Array.isArray(chapter?.stops) + ? chapter.stops.map((stop) => ({ + ...stop, + hunkIds: rewriteIds(stop?.hunkIds), + notes: Array.isArray(stop?.notes) + ? stop.notes.map((note) => ({ + ...note, + hunkId: + typeof note?.hunkId === 'string' + ? hunkIdByAlias.get(note.hunkId) ?? note.hunkId + : note?.hunkId, + })) + : stop?.notes, + })) + : chapter?.stops, + })) + : input.chapters, + support: Array.isArray(input.support) + ? input.support.map((item) => ({ + ...item, + hunkIds: rewriteIds(item?.hunkIds), + })) + : input.support, + }; + } + return authoring.normalizeWalkthroughDraft(draft, state, agent); +}; + +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {unknown} [options] + */ +const buildWalkthroughPrompt = async (state, options) => { + const authoring = await loadAuthoring(); + return authoring.buildWalkthroughPrompt(state, options ?? {}); +}; + +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {unknown} [options] + */ +const buildWalkthroughPromptInput = async (state, options) => { + const authoring = await loadAuthoring(); + return authoring.buildWalkthroughPromptInput(state, options ?? {}); +}; + +module.exports = { + buildWalkthroughPrompt, + buildWalkthroughPromptInput, + loadAuthoring, + normalizeNarrativeWalkthrough, +}; From 978698747323a4f1a859ddcc4b046f686cdddd52 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:34:37 -0500 Subject: [PATCH 17/20] Mount shared review host for local PR/MR sources Route pull-request sources through LocalMergeRequestReviewHost so local Codiff renders MergeRequestReviewApp chrome (tree/walkthrough/comments) instead of the reduced App.tsx PR path. Baseline whole-PR review, comments, and walkthrough generation use existing IPC; version compare stays unwired for the next step. --- core/App.tsx | 24 +- .../LocalMergeRequestReviewHost.test.tsx | 315 +++++++++++ core/__tests__/ReviewCodeView-scroll.test.tsx | 12 +- core/__tests__/helpers/review-code-view.tsx | 2 +- core/app/LocalMergeRequestReviewHost.tsx | 372 +++++++++++++ docs/plan-local-parity.md | 493 ++++++++++++++++++ 6 files changed, 1210 insertions(+), 8 deletions(-) create mode 100644 core/__tests__/LocalMergeRequestReviewHost.test.tsx create mode 100644 core/app/LocalMergeRequestReviewHost.tsx create mode 100644 docs/plan-local-parity.md diff --git a/core/App.tsx b/core/App.tsx index e44197cd..0f5ad074 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -41,6 +41,10 @@ import { } from './app/hooks/useDocumentAppearance.ts'; import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; import { useReviewFileState } from './app/hooks/useReviewState.ts'; +import { + LocalMergeRequestReviewHost, + shouldUseLocalMergeRequestHost, +} from './app/LocalMergeRequestReviewHost.tsx'; import { createDefaultConfig } from './config/defaults.ts'; import { getShortcutLabel } from './config/keymap.ts'; import type { CodiffConfig } from './config/types.ts'; @@ -1591,6 +1595,20 @@ export default function App() { ); } + if (shouldUseLocalMergeRequestHost(state.source)) { + return ( + selectSource({ type: 'working-tree' })} + preferences={preferences} + state={state} + /> + ); + } + const selectedOrSearchPath = activeDiffSearchMatch?.filePath ?? selectedPath; const visibleSelectedPath = selectedOrSearchPath && visibleFiles.some((file) => file.path === selectedOrSearchPath) @@ -1651,6 +1669,7 @@ export default function App() { focusCommentRequest, gitIdentity, hunkNavigation, + isPullRequest, itemVersionByKey, keymap: codiffConfig.keymap, loadingSectionIds, @@ -1684,7 +1703,6 @@ export default function App() { } /> ) : undefined, - supportsReviewCommentActions: isPullRequest, theme: preferences.theme, viewed, wordWrap, @@ -1853,9 +1871,11 @@ export default function App() { narrativeWalkthrough={narrativeWalkthrough} onActivatePath={activatePath} onLoadMoreHistory={loadMoreHistory} + onModeChange={changeSidebarMode} onSearchQueryChange={ sidebarMode === 'history' ? setHistorySearchQuery : setFileSearchQuery } + onSelectPath={activatePath} onSelectSource={selectSource} onShareWalkthrough={enabledShareWalkthrough} onToggleCommitView={showPlainCommitView ? closeCommitView : openCommitView} @@ -1868,7 +1888,9 @@ export default function App() { viewed={viewed} walkthroughError={walkthroughError} walkthroughLoading={walkthroughLoading} + walkthroughOutdatedPaths={reloadDeltaPaths} walkthroughProgress={walkthroughProgress} + walkthroughUnread={walkthroughUnread} />
diff --git a/core/__tests__/LocalMergeRequestReviewHost.test.tsx b/core/__tests__/LocalMergeRequestReviewHost.test.tsx new file mode 100644 index 00000000..3ff1ea94 --- /dev/null +++ b/core/__tests__/LocalMergeRequestReviewHost.test.tsx @@ -0,0 +1,315 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { + LocalMergeRequestReviewHost, + shouldUseLocalMergeRequestHost, +} from '../app/LocalMergeRequestReviewHost.tsx'; +import { createDefaultConfig, defaultSettings } from '../config/defaults.ts'; +import type { NarrativeWalkthrough, RepositoryState, ReviewSource } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const createMemoryStorage = (): Storage => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); +Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); +}); + +const pullRequestSource = { + description: '## Intent\n\nShip **review** context.', + number: 12, + provider: 'github', + title: 'Local shared review host', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', +} satisfies Extract; + +const repositoryState = { + branch: 'feature/local-host', + files: [createChangedFile('src/app.ts')], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: pullRequestSource, +} satisfies RepositoryState; + +const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ + askReviewAssistant: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + hash: '0000000000000000000000000000000000000000', + status: 'committed' as const, + })), + decreaseCodeFontSize: vi.fn(async () => {}), + getAgentSkillStatus: vi.fn(async () => ({ + installed: true, + path: '/Users/reviewer/.codex/skills/codiff', + })), + getConfig: vi.fn(async () => createDefaultConfig()), + getDiffImageContent: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + getDiffSectionContent: vi.fn(async () => { + throw new Error('Unexpected diff section load.'); + }), + getFeatureFlags: vi.fn(async () => ({ + planSharing: false, + walkthroughSharing: false, + })), + getGitIdentity: vi.fn(async () => ({ + email: 'reviewer@example.com', + name: 'Reviewer', + })), + getLaunchOptions: vi.fn(async () => ({ + repositoryPathProvided: true, + walkthrough: false, + })), + getMarkdownDocument: vi.fn(async ({ kind, path }) => ({ + content: '# Plan\n', + id: `${kind}:${path}`, + kind, + path, + version: 'version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getPreferences: vi.fn(async () => ({ + agentBackend: 'codex' as const, + claudeModel: defaultSettings.claudeModel, + codeFontFamily: defaultSettings.codeFontFamily, + codeFontSize: defaultSettings.codeFontSize, + copyCommentsOnClose: true, + diffStyle: 'split' as const, + editorCommand: '', + lastRepositoryPath: '/repo', + openAIModel: defaultSettings.openAIModel, + opencodeModel: defaultSettings.opencodeModel, + piModel: defaultSettings.piModel, + reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix, + showOutdated: false, + showWhitespace: false, + theme: 'system' as const, + walkthroughPrompt: defaultSettings.walkthroughPrompt, + wordWrap: false, + })), + getRepositoryHistory: vi.fn(async () => ({ + entries: [ + { + author: 'Author', + committedAt: Date.parse('2026-07-01T00:00:00.000Z'), + parents: ['parent'], + ref: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + scope: 'pull-request' as const, + subject: 'PR commit', + }, + { + author: 'Base Author', + committedAt: Date.parse('2026-06-01T00:00:00.000Z'), + parents: [], + ref: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + scope: 'base' as const, + subject: 'Base commit', + }, + ], + root: '/repo', + })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + increaseCodeFontSize: vi.fn(async () => {}), + installAgentSkill: vi.fn(async () => ({ + installed: true, + path: '/Users/reviewer/.codex/skills/codiff', + })), + installTerminalHelper: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => () => {}), + onCopyPendingCommentsRequest: vi.fn(() => () => {}), + onFindInDiffs: vi.fn(() => () => {}), + onMarkdownDocumentChanged: vi.fn(() => () => {}), + onPlanCloseRequested: vi.fn(() => () => {}), + onRefreshRequest: vi.fn(() => () => {}), + onRepositoryChanged: vi.fn(() => () => {}), + onWalkthroughCommitOutput: vi.fn(() => () => {}), + onWalkthroughProgress: vi.fn(() => () => {}), + onWindowFullScreenChanged: vi.fn(() => () => {}), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + resetCodeFontSize: vi.fn(async () => {}), + saveMarkdownDocument: vi.fn(async (request) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/test', + })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/w/test', + })), + showInFolder: vi.fn(async () => {}), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Unexpected pull request comment submit.'); + }), + submitPullRequestReview: vi.fn(async () => {}), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +test('shouldUseLocalMergeRequestHost only matches pull-request sources', () => { + expect(shouldUseLocalMergeRequestHost(pullRequestSource)).toBe(true); + expect(shouldUseLocalMergeRequestHost({ type: 'working-tree' })).toBe(false); + expect(shouldUseLocalMergeRequestHost({ ref: 'abc', type: 'commit' })).toBe(false); + expect(shouldUseLocalMergeRequestHost(null)).toBe(false); +}); + +test('App mounts the shared merge-request review shell for pull-request sources', async () => { + window.codiff = createCodiffMock(); + const app = await renderReact(); + + try { + await waitFor(() => { + expect(app.container.querySelector('.merge-request-shell')).not.toBeNull(); + }); + + expect(app.container.querySelector('.merge-request-home-button')).not.toBeNull(); + expect(app.container.querySelector('.review-top-bar-source')?.textContent).toContain('PR #12'); + expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); + expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( + 'Ship review context.', + ); + // Shared chrome uses Comments instead of the local History sidebar mode. + expect( + Array.from(app.container.querySelectorAll('[role="tab"]')).map((tab) => tab.textContent), + ).toEqual(expect.arrayContaining(['Walkthrough', 'Tree', 'Comments'])); + expect(window.codiff.getRepositoryHistory).toHaveBeenCalled(); + } finally { + await app.cleanup(); + } +}); + +test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through local IPC', async () => { + const walkthrough = { + agent: 'codex', + chapters: [], + focus: 'Focus.', + generatedAt: '2026-07-01T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature/local-host', root: '/repo' }, + source: pullRequestSource, + support: [], + title: 'Generated', + version: 4, + } satisfies NarrativeWalkthrough; + + const getNarrativeWalkthrough = vi.fn(async () => ({ + status: 'ready' as const, + walkthrough, + })); + + window.codiff = createCodiffMock({ getNarrativeWalkthrough }); + + const onHome = vi.fn(); + const app = await renderReact( + , + ); + + try { + await waitFor(() => { + expect(getNarrativeWalkthrough).toHaveBeenCalledWith(pullRequestSource, {}); + }); + await waitFor(() => { + // Empty-chapter walkthroughs still mark the surface ready. + expect(app.container.textContent).toContain('This walkthrough has no readable sequence.'); + }); + + await act(async () => { + app.container.querySelector('.merge-request-home-button')?.click(); + }); + expect(onHome).toHaveBeenCalledTimes(1); + } finally { + await app.cleanup(); + } +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index ceee97b0..4c14d526 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -786,7 +786,7 @@ test('read-only review comments render safe details blocks', async () => { } satisfies ReviewComment; const view = await renderReact( - , + , ); try { @@ -1362,9 +1362,9 @@ test('failed pull request comments keep their draft and can be retried', async ( , ); @@ -1410,8 +1410,8 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy , ); @@ -1460,9 +1460,9 @@ test('file comments can be created for GitLab merge requests but not GitHub pull const view = await renderReact( , ); @@ -1483,9 +1483,9 @@ test('file comments can be created for GitLab merge requests but not GitHub pull await view.rerender( , ); expect(view.container.querySelector('.codiff-file-comment-button')).toBeNull(); @@ -1510,12 +1510,12 @@ test('file-level review comments render as measured file annotations', async () }, ]} files={[file]} + isPullRequest source={{ provider: 'gitlab', type: 'pull-request', url: 'https://gitlab.example.com/group/project/-/merge_requests/1', }} - supportsReviewCommentActions />, ); diff --git a/core/__tests__/helpers/review-code-view.tsx b/core/__tests__/helpers/review-code-view.tsx index b9b1c743..edb9e0f5 100644 --- a/core/__tests__/helpers/review-code-view.tsx +++ b/core/__tests__/helpers/review-code-view.tsx @@ -168,6 +168,7 @@ export function ReviewCodeViewHarness({ files, ...overrides }: ReviewCodeViewHar forceExpandedPaths={new Set()} gitIdentity={null} hunkNavigation={null} + isPullRequest={false} itemVersionByKey={{}} keymap={defaultKeymap} loadingSectionIds={new Set()} @@ -187,7 +188,6 @@ export function ReviewCodeViewHarness({ files, ...overrides }: ReviewCodeViewHar selectedPath={null} showWhitespace={false} source={source} - supportsReviewCommentActions={false} viewed={{}} walkthroughNotes={new Map()} wordWrap={false} diff --git a/core/app/LocalMergeRequestReviewHost.tsx b/core/app/LocalMergeRequestReviewHost.tsx new file mode 100644 index 00000000..beeb4faf --- /dev/null +++ b/core/app/LocalMergeRequestReviewHost.tsx @@ -0,0 +1,372 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { CodiffConfig } from '../config/types.ts'; +import { sortFiles } from '../lib/files.ts'; +import { getSourceLabel } from '../lib/source.ts'; +import { + MergeRequestReviewApp, + type MergeRequestCommitListEntry, + type MergeRequestReviewMode, + type MergeRequestWalkthroughStatus, +} from '../SharedWalkthroughApp.tsx'; +import type { + ChangedFile, + CodiffPreferences, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + PullRequestExistingReviewComment, + PullRequestReviewComment, + PullRequestReviewEvent, + RepositoryState, + ReviewSource, +} from '../types.ts'; + +const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ + ...settings, +}); + +const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); + +const toCommitListEntries = ( + entries: ReadonlyArray, +): ReadonlyArray => + entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => ({ + authoredAt: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + subject: entry.subject, + })); + +const getPullRequestTitle = (state: RepositoryState) => { + if (state.source.type !== 'pull-request') { + return 'Review'; + } + return state.source.title?.trim() || getSourceLabel(state.source); +}; + +const getSourceIdentityKey = (source: ReviewSource) => + source.type === 'pull-request' + ? `pull-request:${source.provider ?? ''}:${source.url}` + : `${source.type}`; + +const sortRepositoryState = (state: RepositoryState): RepositoryState => ({ + ...state, + files: sortFiles(state.files), +}); + +const unsupportedGeneralComment = async (): Promise => { + throw new Error('General discussion comments are not supported in local Codiff yet.'); +}; + +const unsupportedCommentUpdate = async (): Promise => { + throw new Error('Editing submitted review comments is not supported in local Codiff yet.'); +}; + +export type LocalMergeRequestReviewHostProps = { + gitIdentity?: GitIdentity | null; + initialMode?: MergeRequestReviewMode; + onHome: () => void; + preferences?: Partial< + Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + > + >; + /** Preloaded snapshot handed off from App bootstrap. */ + state: RepositoryState; +}; + +type HostSessionProps = LocalMergeRequestReviewHostProps & { + sourceKey: string; +}; + +/** + * One session per PR/MR identity. App remounts this when the source key changes + * so we avoid setState-in-effect reset patterns. + */ +function LocalMergeRequestReviewSession({ + gitIdentity = null, + initialMode, + onHome, + preferences: preferencesProp, + state: initialState, +}: HostSessionProps) { + const [liveState, setLiveState] = useState(null); + const state = liveState ?? sortRepositoryState(initialState); + const stateRef = useRef(state); + const [commits, setCommits] = useState>([]); + const [walkthrough, setWalkthrough] = useState(null); + const [walkthroughStatus, setWalkthroughStatus] = + useState('idle'); + const [walkthroughError, setWalkthroughError] = useState(null); + const [configPreferences, setConfigPreferences] = useState(defaultPreferences); + const [mode, setMode] = useState(initialMode); + const [selectedCommitSha, setSelectedCommitSha] = useState(null); + const commitDiffCacheRef = useRef>>(new Map()); + const walkthroughRequestRef = useRef(0); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + let canceled = false; + window.codiff.getConfig().then( + (config) => { + if (!canceled) { + setConfigPreferences(getPreferencesFromConfig(config)); + } + }, + () => {}, + ); + const unsubscribe = window.codiff.onConfigChanged((config) => { + setConfigPreferences(getPreferencesFromConfig(config)); + }); + return () => { + canceled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (state.source.type !== 'pull-request') { + return; + } + + let canceled = false; + const source = state.source; + + const loadCommits = async () => { + const history = await window.codiff.getRepositoryHistory(200, source); + if (canceled) { + return; + } + setCommits(toCommitListEntries(history.entries)); + }; + + loadCommits().catch(() => { + if (!canceled) { + setCommits([]); + } + }); + + return () => { + canceled = true; + }; + }, [state.source]); + + const refreshState = useCallback(async () => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + const nextState = await window.codiff.getRepositoryState(current.source); + const ordered = sortRepositoryState(nextState); + stateRef.current = ordered; + setLiveState(ordered); + commitDiffCacheRef.current.clear(); + setSelectedCommitSha(null); + }, []); + + useEffect(() => window.codiff.onRefreshRequest(() => void refreshState()), [refreshState]); + + const onSubmitComment = useCallback( + async (comment: PullRequestReviewComment): Promise => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + throw new Error('Review comments require a pull request source.'); + } + const submitted = await window.codiff.submitPullRequestComment({ + comment, + source: current.source, + }); + await refreshState(); + return submitted; + }, + [refreshState], + ); + + const onSubmitReview = useCallback( + async ( + event: PullRequestReviewEvent, + comments: ReadonlyArray, + body?: string, + ) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + throw new Error('Reviews require a pull request source.'); + } + await window.codiff.submitPullRequestReview({ + ...(body ? { body } : {}), + comments, + event, + source: current.source, + }); + await refreshState(); + }, + [refreshState], + ); + + const onSubmitGeneralComment = useCallback(async (_body: string) => { + await unsupportedGeneralComment(); + }, []); + + const onUpdateComment = useCallback(async (_commentId: string, _body: string) => { + await unsupportedCommentUpdate(); + }, []); + + const onUpdateGeneralComment = useCallback(async (_commentId: string, _body: string) => { + await unsupportedCommentUpdate(); + }, []); + + const onLoadCommitDiff = useCallback(async (sha: string) => { + const cached = commitDiffCacheRef.current.get(sha); + if (cached) { + setSelectedCommitSha(sha); + return cached; + } + const commitState = await window.codiff.getRepositoryState({ + ref: sha, + type: 'commit', + } satisfies ReviewSource); + const files = sortFiles(commitState.files); + commitDiffCacheRef.current.set(sha, files); + setSelectedCommitSha(sha); + return files; + }, []); + + const onGenerateWalkthrough = useCallback( + async (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + // Version-scoped / unit generation lands with history wiring; baseline whole-diff only. + if (options?.versionCompare || options?.unitId) { + setWalkthroughStatus('failed'); + setWalkthroughError( + 'Version compare and unit walkthroughs are not wired in local Codiff yet.', + ); + return; + } + if (current.files.length === 0) { + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + return; + } + + const requestId = ++walkthroughRequestRef.current; + setWalkthroughStatus('generating'); + setWalkthroughError(null); + try { + const result = await window.codiff.getNarrativeWalkthrough( + current.source, + options?.force ? { force: true } : {}, + ); + if (requestId !== walkthroughRequestRef.current) { + return; + } + if (result.status === 'ready') { + setWalkthrough(result.walkthrough); + setWalkthroughStatus('ready'); + setWalkthroughError(null); + return; + } + setWalkthrough(null); + setWalkthroughStatus('failed'); + setWalkthroughError(result.reason); + } catch (error: unknown) { + if (requestId !== walkthroughRequestRef.current) { + return; + } + setWalkthrough(null); + setWalkthroughStatus('failed'); + setWalkthroughError(error instanceof Error ? error.message : String(error)); + } + }, + [], + ); + + const title = useMemo(() => getPullRequestTitle(state), [state]); + const externalUrl = state.source.type === 'pull-request' ? state.source.url : ''; + + const resolvedPreferences = useMemo( + () => ({ + codeFontFamily: preferencesProp?.codeFontFamily ?? configPreferences.codeFontFamily, + codeFontSize: preferencesProp?.codeFontSize ?? configPreferences.codeFontSize, + diffStyle: preferencesProp?.diffStyle ?? configPreferences.diffStyle, + showWhitespace: preferencesProp?.showWhitespace ?? configPreferences.showWhitespace, + theme: preferencesProp?.theme ?? configPreferences.theme, + wordWrap: preferencesProp?.wordWrap ?? configPreferences.wordWrap, + }), + [configPreferences, preferencesProp], + ); + + if (state.source.type !== 'pull-request') { + return null; + } + + return ( + { + // History wiring lands in a later plan step. + }} + onGenerateWalkthrough={onGenerateWalkthrough} + onHome={onHome} + onLoadCommitDiff={onLoadCommitDiff} + onModeChange={setMode} + onOpenVersionCompare={() => { + // History wiring lands in a later plan step. + }} + onSubmitComment={onSubmitComment} + onSubmitGeneralComment={onSubmitGeneralComment} + onSubmitReview={onSubmitReview} + onUpdateComment={onUpdateComment} + onUpdateGeneralComment={onUpdateGeneralComment} + onVersionCompareRangeChange={() => { + // History wiring lands in a later plan step. + }} + preferences={resolvedPreferences} + selectedCommitSha={selectedCommitSha} + state={state} + title={title} + versionHistoryLoading={false} + versions={[]} + walkthrough={walkthrough} + walkthroughError={walkthroughError} + walkthroughStatus={walkthroughStatus} + /> + ); +} + +/** + * Desktop host adapter for pull-request / merge-request sources. + * Feeds Core `MergeRequestReviewApp` with local IPC-backed data and actions. + * Version compare / evolution props stay empty until later plan steps. + */ +export function LocalMergeRequestReviewHost(props: LocalMergeRequestReviewHostProps) { + const sourceKey = getSourceIdentityKey(props.state.source); + return ; +} + +export function shouldUseLocalMergeRequestHost(source: ReviewSource | null | undefined): boolean { + return source?.type === 'pull-request'; +} diff --git a/docs/plan-local-parity.md b/docs/plan-local-parity.md new file mode 100644 index 00000000..5242afbf --- /dev/null +++ b/docs/plan-local-parity.md @@ -0,0 +1,493 @@ +# Plan: Local Codiff Review-History Parity + +## Goal + +Bring local Codiff to product parity with Codiff Web's review-history experience: + +- pick historical review endpoints +- compare two endpoints as a review surface +- classify commit-stack evolution +- generate whole-diff or unit walkthroughs through shared authoring + +Do this on top of the already-extracted packages: + +- `@nkzw/codiff-core` contracts + `MergeRequestReviewApp` + `walkthrough-authoring` +- `@nkzw/codiff-gitlab` read-side GitLab adapter + `GitLabTransport` +- local `createGlabGitLabTransport` + +Codiff Web remains the Cloudflare host. Local Codiff becomes the desktop host. +Both should consume the same packages and render the same Core review UI. + +## Non-goals + +- Web D1/R2 caches, Durable Objects, Fate live updates, Think/AI Gateway +- migrating baseline current-MR/PR loading or write-side comments/reviews yet +- full Jujutsu identity / evolution-log support +- making GitHub invent GitLab-style numbered versions + +Local parity means **same review semantics and UX**, not the same cloud +orchestration stack. + +## Current State + +### Already shared + +- Core review-history contracts (`DiffRange`, `DiffComparison`, units, plans) +- Core review UI that can render versions / compare / evolution when fed props +- Walkthrough authoring (`parse` / `prompt` / `normalize` / `compose`) +- GitLab history package over injected transport +- Local glab transport primitive +- Local whole-diff walkthrough generation routed through authoring + +### Still local-only / missing + +- Electron does **not** call `@nkzw/codiff-gitlab` for versions/compare/evolution +- Local PR/MR UI still primarily goes through `core/App.tsx`, not the full + `MergeRequestReviewApp` host loop used by Web +- No local unit-walkthrough orchestration +- No GitHub force-push / head-comparison history provider +- Web still has in-tree copies of GitLab history + authoring (migration later) + +## Target Architecture + +```text +@nkzw/codiff-core + contracts, MergeRequestReviewApp, walkthrough-authoring + +@nkzw/codiff-gitlab + GitLab versions / compare / evolution / unit diffs / plans + over GitLabTransport + +@nkzw/codiff-github (new, or electron/git-state/github-history first) + GitHub force-push heads / compare / evolution / plans + over GitHubTransport + +local Codiff host + glab transport + gh transport + IPC + local agent invocation + mounts MergeRequestReviewApp for PR/MR sources + +Codiff Web host + Worker GitLabTransport (+ later GitHub if needed) + Fate / DO / Think / D1 / R2 + same MergeRequestReviewApp props +``` + +### Host boundary + +Hosts own: + +- authentication (`glab`, `gh`, Worker bridge) +- process execution and credentials +- generation runtime (local agents vs Think) +- optional caching +- IPC / route state + +Packages own: + +- endpoint construction and provider semantics +- comparison / evolution algorithms +- projection into Core contracts +- walkthrough draft/prompt/normalize/compose + +## Provider Models + +### GitLab: native MR versions + +Use GitLab's first-class version history. + +Endpoints / package APIs: + +- version list + stats +- version compare (rebase-replay preferred) +- historical commit stacks +- commit evolution + rebase drivers +- unit diffs +- `projectVersionCompare` / `projectCommitEvolution` / `projectReviewPlan` + +Local transport: + +```ts +createGlabGitLabTransport({ hostname, repoRoot }); +``` + +Identity remains GitLab `{ baseSha, startSha, headSha }` inside the package. +Core only sees `DiffRange` / `ReviewVersionOption`. + +### GitHub: force-push head comparisons + +GitHub has no MR version list. Local parity uses **force-push head history** as +the revision timeline. + +#### Revision discovery + +Build an ordered list of PR head snapshots from: + +1. PR timeline / issue events for force-push records + - before/after head SHAs + - actor + timestamp +2. current PR head as the newest endpoint +3. optional: PR base updates as base-movement context, not as head versions + +Each revision becomes a Core `ReviewVersionOption`: + +```ts +{ + id: afterHeadSha, // or stable derived id + createdAt, + number: index, // display only + range: { + base: baseAtThatTimeOrCurrentBase, + head: afterHeadSha, + } +} +``` + +Labels should read like: + +- `Head · abc1234` +- `Force-push · def5678` +- `Current head` + +not fake `v1/v2` GitLab version numbers unless we explicitly choose that UX. + +#### Comparison materialization + +For selected before/after heads: + +1. resolve base refs (PR base at each point if available; else current base) +2. materialize `before = baseA...headA`, `after = baseB...headB` +3. prefer a rebase-replay / intentional interdiff path when blobs are available + via local git objects +4. fall back to approximate patch-text compare when objects are missing +5. project to `DiffComparisonView` + +Local git is an advantage here: after `gh` fetches the PR refs, many head SHAs +are already present and can be read without remote blob round-trips. + +#### Commit evolution + +Reuse the same Core/GitLab evolution concepts: + +- retained +- rewritten-same-patch +- revised +- introduced +- removed +- absorbed-into-base +- ambiguous + +Implementation options, in order of preference: + +1. extract provider-neutral evolution pure functions already used by GitLab into + Core or a tiny shared history module, then call them from GitHub +2. temporarily fork the algorithm behind `@nkzw/codiff-github` if extraction is + too large for the first local commit + +Do not special-case the UI for GitHub evolution kinds. + +#### Comments + +Associate review comments to head SHAs / positions when GitHub provides them. +Comment association quality may be weaker than GitLab's versioned positions; +surface warnings rather than inventing certainty. + +## Local Product UX + +For `source.type === 'pull-request'`, local Codiff should mount the shared +review surface used by Web (`MergeRequestReviewApp` / `ReviewSurface`), not a +reduced PR-only subset of `App.tsx`. + +Required host-fed props: + +- `state` current whole PR/MR snapshot +- `versions` +- `versionCompare` / loading / error +- `versionCommitEvolution` / loading / error +- `versionWalkthroughStructure` +- `commits` for ordinary commit-by-commit when useful +- callbacks: + - `onVersionCompareRangeChange` + - `onLoadCommitDiff` + - `onLoadVersionCommitDiff` + - `onGenerateWalkthrough` + - exit compare / open compare + +### Modes + +Preserve existing review modes: + +- comments +- tree / files +- commits / evolution +- walkthrough + +Version compare is a scope over tree + walkthrough, as in Web. + +### Walkthrough generation + +Whole-diff: + +1. materialize one `RepositoryState` for the selected range/compare +2. `buildWalkthroughPrompt` + local agent +3. `normalizeWalkthroughDraft` + +Units: + +1. `projectReviewPlan` / structure override +2. for each reviewable unit, materialize unit `RepositoryState` +3. generate each unit independently through authoring +4. `composeUnitWalkthroughs` +5. attach comment references when available + +Local generation stays sequential or lightly concurrent in-process. No DO fanout +required for v1. + +## Implementation Plan + +### 1. Local review host shell + +Switch local PR/MR presentation to the shared review app host path. + +Work: + +- add an Electron/renderer host container for pull-request sources +- keep working-tree / commit / branch modes on existing `App.tsx` paths +- plumb preload/IPC for history reads and scoped generation +- preserve comment submit/review actions already implemented locally + +Acceptance: + +- opening `codiff mr …` or `codiff pr …` renders shared review chrome +- baseline whole-MR/PR review still works with no version compare selected + +### 2. GitLab local history wiring + +Work: + +- on GitLab MR load, create `createGlabGitLabTransport` +- fetch version history through `@nkzw/codiff-gitlab` +- fetch compare + evolution on range selection +- fetch unit diffs on demand +- project package results into Core props +- wire generate callbacks to shared authoring + local agents + +Acceptance: + +- version picker populated from glab-backed transport +- compare shows intentional files / base movement / evolution +- whole-diff and commit-unit walkthroughs both work offline-ish with glab auth + +Tests: + +- fake glab executable covering versions, compare payloads, commit stacks +- unit tests for IPC/host mapping to Core props +- one end-to-end Electron test with fixture transport + +### 3. GitHub force-push history provider + +Work: + +- add `GitHubTransport` + `createGhGitHubTransport` +- implement revision discovery from force-push timeline/head events +- implement compare materialization using local git objects when possible +- implement evolution + plan projection to Core contracts +- wire into the same host shell as GitLab + +Package shape preference: + +```text +@nkzw/codiff-github + transport interface + force-push revision list + compare/evolution/plan projection +``` + +If packaging slows the first landing, start under +`electron/git-state/github-history/` with the same public interface and move it +into a package in the release commit. + +Acceptance: + +- PR with force-pushes shows a head timeline +- comparing two heads materializes an interdiff-like review surface +- evolution units render in the same UI as GitLab +- no GitLab version numbering metaphors required + +Tests: + +- fake `gh` executable / fixture timeline payloads +- compare fixtures for rewritten commits and pure force-push rebases +- warnings when before/after heads are unavailable locally and must be fetched + +### 4. Shared generation orchestration helper + +Avoid two host-specific generators. + +Work: + +- add a small host-agnostic helper in Core or a local shared module: + +```ts +generateReviewWalkthrough({ + plan, + states, // whole or per-unit + runModel, // host-provided + agent, + compose, +}); +``` + +- local agents implement `runModel` +- Web Think path can adopt later + +Acceptance: + +- local GitLab and GitHub both call the same orchestration helper +- composition and prompt options remain in `walkthrough-authoring` + +### 5. UX polish and parity gaps + +Work: + +- structure recommendation + user override +- empty/error/loading states for history reads +- comment open-in-compare affordances where positions exist +- clear provider-specific copy: + - GitLab: “Versions” + - GitHub: “Head history” / “Force-pushes” +- ensure CSS already in Core covers both + +### 6. Release / docs + +Work: + +- bump package versions if GitHub package or Core orchestration lands +- update `docs/review-history-packages.md` +- document local commands and auth requirements +- keep Web migration notes explicit: local proves host wiring first + +## Commit Plan + +Every commit should build and test independently. + +1. `Mount shared review host for local PR/MR sources` + - shell/IPC only; no GitLab/GitHub history algorithms yet +2. `Wire GitLab review history into local Codiff` + - glab transport + `@nkzw/codiff-gitlab` + generation +3. `Add GitHub force-push review history provider` + - transport + revision/compare/evolution + host wiring +4. `Unify local walkthrough generation behind shared orchestration` +5. `Polish provider copy/tests and release local parity packages` + +If commit 3 is large, split into: + +- provider package/fixtures +- Electron wiring + +## Unification With Codiff Web + +This local work is not a fork. It is the second host on the same packages. + +Immediate: + +- local consumes released/shared packages +- proves the host adapter pattern with real UX + +Next Web MR: + +- delete in-tree GitLab history + authoring copies +- implement Worker `GitLabTransport` +- keep only Cloudflare orchestration/persistence + +Eventually: + +```text +one Core UI +one authoring module +provider packages per forge +thin hosts +``` + +Local-specific forever: + +- glab/gh executable discovery +- local agent backends +- no multi-tenant cache + +Web-specific forever: + +- Access/Worker auth +- D1/R2/DO/Fate/Think + +## Auth and Runtime Requirements + +GitLab local: + +- `glab` installed and authenticated for the MR host +- `CODIFF_GLAB_PATH` supported +- network access to GitLab API via glab + +GitHub local: + +- `gh` installed and authenticated +- fetch enough PR refs/commits to materialize selected heads +- network access for timeline + missing objects + +Walkthroughs: + +- at least one local agent backend (`codex` / `claude` / `opencode` / `pi`) + +## Validation + +### GitLab + +- open MR with 3+ versions +- compare adjacent and distant versions +- verify base movement and evolution units +- generate whole-diff walkthrough for a compare +- generate commit-unit walkthrough and navigate unit chapters +- fake-glab tests for transport and host mapping + +### GitHub + +- open PR with force-push history +- verify head timeline ordering and labels +- compare pre/post force-push heads +- verify rewritten/introduced/removed classification on a known fixture +- generate whole-diff and unit walkthroughs +- fake-gh tests for timeline parsing and compare materialization + +### Cross-provider + +- same Core props shape into `MergeRequestReviewApp` +- same authoring module output schema +- no provider-specific React forks inside Core beyond label copy + +### Regression + +- working-tree review/commit flow unchanged +- ordinary PR/MR comments and submit review still work +- `codiff -w` whole-diff still works outside compare mode + +## Risks + +- **GitHub timeline completeness:** force-push events may be missing or partial on + some repos/permissions. Degrade to current head only with an explicit warning. +- **Object availability:** selected heads may not exist locally. Fetch on demand; + fail soft with actionable auth/fetch errors. +- **Algorithm drift:** do not copy evolution code differently for GitHub. Extract + shared pure functions before dual maintenance appears. +- **UI mount churn:** moving local PR/MR onto `MergeRequestReviewApp` can regress + comments/review buttons if actions are not plumbed carefully. +- **Performance:** naive full compare on every range change will feel slow. + Cache last compare/evolution in memory per window; no durable cache required. + +## Decision Summary + +- Yes: implement local parity now. +- GitLab: package-backed native versions. +- GitHub: force-push head comparisons projected into the same Core model. +- Keep hosts thin; keep algorithms/authoring shared. +- Use local parity to finish the unification path that lets Codiff Web delete its + duplicated history/authoring implementations. From 72ca9a31493fab8ef2cb3074a6670748b11e7cb8 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:49 -0500 Subject: [PATCH 18/20] Add a local GitLab CLI transport Implement the `GitLabTransport` contract over authenticated `glab api` calls and expose it from the local merge-request backend. This isolates process execution and pagination from the shared GitLab provider package before the review host starts loading version history. --- .../__tests__/glab-gitlab-transport.test.ts | 79 +++++++ electron/git-state/glab-gitlab-transport.cjs | 199 ++++++++++++++++++ electron/git-state/merge-request.cjs | 3 + 3 files changed, 281 insertions(+) create mode 100644 electron/__tests__/glab-gitlab-transport.test.ts create mode 100644 electron/git-state/glab-gitlab-transport.cjs diff --git a/electron/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts new file mode 100644 index 00000000..41d559c3 --- /dev/null +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -0,0 +1,79 @@ +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transport.cjs') as { + createGlabGitLabTransport: (options: { + hostname: string; + repoRoot: string; + }) => { + request: (request: { + method?: string; + path: string; + query?: Record; + body?: unknown; + }) => Promise; + }; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; + +afterEach(() => { + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } +}); + +test('createGlabGitLabTransport performs glab api requests through the injected executable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const callsPath = process.env.CODIFF_GLAB_TEST_CALLS; +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(callsPath, JSON.stringify({ args, input }) + '\\n'); + if (args.includes('/api/v4/projects/group%2Fproject/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 1, + head_commit_sha: 'b'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('{}'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const versions = await transport.request>({ + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + }); + expect(versions).toEqual([ + expect.objectContaining({ id: 1 }), + ]); +}); diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs new file mode 100644 index 00000000..b77b6cc5 --- /dev/null +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -0,0 +1,199 @@ +// @ts-check + +/** + * glab-backed GitLabTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { accessSync, constants } = require('node:fs'); +const { homedir } = require('node:os'); +const { delimiter, join } = require('node:path'); + +const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; +const GLAB_NOT_FOUND_MESSAGE = + 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; + +/** @param {string} path */ +const isExecutableFile = (path) => { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +}; + +/** @param {string} command */ +const findExecutableOnPath = (command) => { + const pathValue = process.env.PATH ?? ''; + for (const directory of pathValue.split(delimiter)) { + if (!directory) continue; + const candidate = join(directory, command); + if (isExecutableFile(candidate)) { + return candidate; + } + } + return null; +}; + +/** @param {string} [detail] */ +const createGlabNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE) + ); + error.code = GLAB_NOT_FOUND_CODE; + return error; +}; + +const getGlabCommand = () => { + const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); + if (glabPath) { + if (isExecutableFile(glabPath)) { + return glabPath; + } + throw createGlabNotFoundError( + `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('glab'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/glab'), + '/opt/homebrew/bin/glab', + '/usr/local/bin/glab', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGlabNotFoundError(); +}; + +/** + * @param {string} hostname + * @param {string} path + * @param {Readonly> | undefined} query + * @param {string | undefined} method + * @param {unknown} body + */ +const createGlabApiArgs = (hostname, path, query, method, body) => { + const url = new URL(path, 'https://gitlab.local'); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + } + const resource = `${url.pathname}${url.search}`; + /** @type {Array} */ + const args = ['api', '--hostname', hostname, resource]; + if (method && method !== 'GET') { + args.push('--method', method); + } + if (body != null) { + args.push('--header', 'Content-Type: application/json', '--input', '-'); + } + return args; +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + */ +const runGlabApi = (repoRoot, hostname, args, input) => + new Promise((resolve, reject) => { + let command; + try { + command = getGlabCommand(); + } catch (error) { + reject(error); + return; + } + + const child = spawn(command, args, { + cwd: repoRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', (error) => { + reject(/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' ? createGlabNotFoundError() : error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject( + new Error( + Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, + ), + ); + } + }); + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + }); + +/** + * @param {{ hostname: string, repoRoot: string }} options + */ +const createGlabGitLabTransport = ({ hostname, repoRoot }) => { + /** + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * }} request + */ + const requestText = async (request) => { + const args = createGlabApiArgs( + hostname, + request.path, + request.query, + request.method, + request.body, + ); + return runGlabApi(repoRoot, hostname, args, request.body); + }; + + return { + /** + * @template T + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + return /** @type {T} */ (JSON.parse(text)); + }, + requestText, + }; +}; + +module.exports = { + GLAB_NOT_FOUND_CODE, + createGlabGitLabTransport, + createGlabNotFoundError, + getGlabCommand, +}; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index ddf179dc..5fbc7d91 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -740,7 +740,10 @@ const readMergeRequestImageContent = async (launchPath, source, requestedPath) = } }; +const { createGlabGitLabTransport, getGlabCommand: getGlabCommandFromTransport } = require('./glab-gitlab-transport.cjs'); + module.exports = { + createGlabGitLabTransport, createGitLabPosition, createMergeRequestFetchRefspecs, listMergeRequestHistory, From 8f17d3293e1b5bab5e61b16fae9a93470284f5f5 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:54:28 -0500 Subject: [PATCH 19/20] Load GitLab review history in local Codiff Bridge the shared GitLab provider package into Electron and implement local version, comparison, evolution, and unit-diff loading for merge requests. Keep this backend provider-specific so its CLI transport and projection behavior can be reviewed independently from GitHub force-push handling. --- .../__tests__/gitlab-review-history.test.ts | 90 ++++++ electron/git-state/gitlab-review-history.cjs | 291 ++++++++++++++++++ electron/gitlab-history-bridge.cjs | 23 ++ 3 files changed, 404 insertions(+) create mode 100644 electron/__tests__/gitlab-review-history.test.ts create mode 100644 electron/git-state/gitlab-review-history.cjs create mode 100644 electron/gitlab-history-bridge.cjs diff --git a/electron/__tests__/gitlab-review-history.test.ts b/electron/__tests__/gitlab-review-history.test.ts new file mode 100644 index 00000000..acedbc94 --- /dev/null +++ b/electron/__tests__/gitlab-review-history.test.ts @@ -0,0 +1,90 @@ +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { listGitLabReviewVersions } = require('../git-state/gitlab-review-history.cjs') as { + listGitLabReviewVersions: ( + repoRoot: string, + source: { + host: string; + number: number; + projectPath: string; + provider: 'gitlab'; + type: 'pull-request'; + url: string; + }, + ) => Promise< + ReadonlyArray<{ + id: string; + isHead?: boolean; + number?: number; + range: { head: { commitId: string } }; + }> + >; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; + +afterEach(() => { + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } +}); + +test('listGitLabReviewVersions projects glab version payloads into Core options', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gitlab-history-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const args = process.argv.slice(2); +const resource = args.find((arg) => arg.startsWith('/api/')) || args.at(-1) || ''; +process.stdin.resume(); +process.stdin.on('end', () => { + if (resource.includes('/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 2, + head_commit_sha: ${JSON.stringify('c'.repeat(40))}, + base_commit_sha: ${JSON.stringify('a'.repeat(40))}, + start_commit_sha: ${JSON.stringify('a'.repeat(40))}, + created_at: '2026-01-02T00:00:00.000Z', + }, + { + id: 1, + head_commit_sha: ${JSON.stringify('b'.repeat(40))}, + base_commit_sha: ${JSON.stringify('a'.repeat(40))}, + start_commit_sha: ${JSON.stringify('a'.repeat(40))}, + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('[]'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + + const versions = await listGitLabReviewVersions(directory, { + host: 'gitlab.example.com', + number: 7, + projectPath: 'group/project', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + }); + + expect(versions.map((version) => version.id)).toEqual(['mr-base', '1', '2']); + expect(versions[0]?.number).toBe(0); + expect(versions[2]?.isHead).toBe(true); + expect(versions[2]?.range.head.commitId).toBe('c'.repeat(40)); +}); diff --git a/electron/git-state/gitlab-review-history.cjs b/electron/git-state/gitlab-review-history.cjs new file mode 100644 index 00000000..d4d13a4c --- /dev/null +++ b/electron/git-state/gitlab-review-history.cjs @@ -0,0 +1,291 @@ +// @ts-check + +/** + * Local GitLab review-history adapter over glab transport + @nkzw/codiff-gitlab. + */ + +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); + +/** + * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../core/types.ts').DiffComparisonView} DiffComparisonView + * @typedef {import('../../core/types.ts').ReviewCommitEvolution} ReviewCommitEvolution + * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource + * @typedef {import('../../core/types.ts').ReviewVersionOption} ReviewVersionOption + * @typedef {Extract} PullRequestSource + */ + +const MR_BASE_VERSION_ID = 'mr-base'; + +/** + * @param {PullRequestSource} source + */ +const assertGitLabSource = (source) => { + if (source.provider !== 'gitlab') { + throw new Error('GitLab review history requires a GitLab merge request source.'); + } + if (!source.host?.trim()) { + throw new Error('GitLab review history requires a host on the merge request source.'); + } + if (!source.projectPath?.trim()) { + throw new Error('GitLab review history requires a projectPath on the merge request source.'); + } + if (!source.number || !Number.isInteger(source.number) || source.number <= 0) { + throw new Error('GitLab review history requires a merge request number.'); + } + return { + host: source.host, + iid: source.number, + projectPath: source.projectPath, + }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + */ +const createTransportForSource = (repoRoot, source) => { + const { host } = assertGitLabSource(source); + return createGlabGitLabTransport({ hostname: host, repoRoot }); +}; + +/** + * @param {import('../../gitlab/src/version-compare.ts').MergeRequestVersionRef} version + * @param {{ + * diffStat?: ReviewVersionOption['diffStat'], + * isHead?: boolean, + * number?: number, + * previousCreatedAt?: string, + * previousNumber?: number, + * }} [extra] + * @param {typeof import('../../gitlab/dist/index.mjs')} gitlab + */ +const toReviewVersionOption = (version, extra, gitlab) => + gitlab.projectMergeRequestVersionRef({ + ...version, + ...(extra?.diffStat ? { diffStat: extra.diffStat } : {}), + ...(extra?.isHead != null ? { isHead: extra.isHead } : {}), + ...(extra?.number != null ? { number: extra.number } : {}), + ...(extra?.previousCreatedAt ? { previousCreatedAt: extra.previousCreatedAt } : {}), + ...(extra?.previousNumber != null ? { previousNumber: extra.previousNumber } : {}), + }); + +/** + * Build Core version options including a synthetic MR-base endpoint so the UI + * can compare base → v1 the same way Web does. + * + * @param {string} repoRoot + * @param {PullRequestSource} source + * @returns {Promise>} + */ +const listGitLabReviewVersions = async (repoRoot, source) => { + const { iid, projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + const versions = await gitlab.fetchGitLabMergeRequestVersions({ + iid, + projectPath, + transport, + }); + if (versions.length === 0) { + return []; + } + + // Oldest → newest for picker defaults (from=previous, to=head). + const chronological = [...versions].toReversed(); + const first = chronological[0]; + const baseOption = toReviewVersionOption( + { + baseSha: first.baseSha, + createdAt: first.createdAt, + headSha: first.baseSha, + id: MR_BASE_VERSION_ID, + label: 'MR base', + startSha: first.baseSha, + }, + { + isHead: false, + number: 0, + }, + gitlab, + ); + + const options = chronological.map((version, index) => + toReviewVersionOption( + version, + { + isHead: index === chronological.length - 1, + number: index + 1, + ...(index > 0 + ? { + previousCreatedAt: chronological[index - 1].createdAt, + previousNumber: index, + } + : {}), + }, + gitlab, + ), + ); + + return [baseOption, ...options]; +}; + +/** + * @param {string} versionId + * @param {ReadonlyArray} versions + * @returns {import('../../gitlab/src/version-compare.ts').VersionCompareEndpoint} + */ +const toCompareEndpoint = (versionId, versions) => { + if (versionId === MR_BASE_VERSION_ID) { + return { kind: 'mr-base' }; + } + if (versions.some((version) => version.id === versionId)) { + return { kind: 'mr-version', versionId }; + } + // Fall back to head-sha if the UI passed a synthetic head id. + if (/^[0-9a-f]{7,40}$/i.test(versionId)) { + return { headSha: versionId, kind: 'head-sha' }; + } + return { kind: 'mr-version', versionId }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {{ fromId: string, toId: string }} range + * @returns {Promise<{ + * versionCompare: DiffComparisonView, + * versionCommitEvolution: ReviewCommitEvolution | null, + * versionCommitEvolutionError: string | null, + * }>} + */ +const compareGitLabReviewVersions = async (repoRoot, source, range) => { + const { iid, projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + const versions = await gitlab.fetchGitLabMergeRequestVersions({ + iid, + projectPath, + transport, + }); + if (versions.length === 0) { + throw new Error('GitLab did not return merge request versions.'); + } + + const fromEndpoint = toCompareEndpoint(range.fromId, versions); + const toEndpoint = toCompareEndpoint(range.toId, versions); + + const [compareResult, evolutionResult] = await Promise.allSettled([ + gitlab.fetchGitLabMergeRequestVersionCompare({ + from: fromEndpoint, + iid, + projectPath, + to: toEndpoint, + transport, + }), + gitlab.fetchGitLabMergeRequestVersionCommitEvolution({ + from: fromEndpoint, + iid, + projectPath, + to: toEndpoint, + transport, + }), + ]); + + if (compareResult.status !== 'fulfilled') { + throw compareResult.reason instanceof Error + ? compareResult.reason + : new Error(String(compareResult.reason)); + } + + const versionCompare = gitlab.projectVersionCompare(compareResult.value); + if (evolutionResult.status === 'fulfilled') { + return { + versionCommitEvolution: gitlab.projectCommitEvolution(evolutionResult.value), + versionCommitEvolutionError: null, + versionCompare, + }; + } + + return { + versionCommitEvolution: null, + versionCommitEvolutionError: + evolutionResult.reason instanceof Error + ? evolutionResult.reason.message + : String(evolutionResult.reason), + versionCompare, + }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {import('../../gitlab/src/version-commit-evolution.ts').VersionCommitEvolutionUnit | import('../../core/types.ts').ReviewEvolutionUnit} unit + * @returns {Promise>} + */ +const loadGitLabVersionCommitUnitDiff = async (repoRoot, source, unit) => { + const { projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + // Core projected units use kind names like 'introduced'/'removed'; package + // algorithm units use 'added'/'removed'/'likely-revised'. Prefer the raw unit + // shape when the host still holds algorithm units; otherwise reconstruct. + /** @type {any} */ + const algorithmUnit = unit; + if ( + algorithmUnit.kind === 'added' || + algorithmUnit.kind === 'removed' || + algorithmUnit.kind === 'likely-revised' || + algorithmUnit.kind === 'unchanged' || + algorithmUnit.kind === 'same-patch' || + algorithmUnit.kind === 'absorbed-into-base' || + algorithmUnit.kind === 'unmatched-old' || + algorithmUnit.kind === 'unmatched-new' + ) { + return gitlab.fetchGitLabVersionCommitUnitDiff({ + projectPath, + transport, + unit: algorithmUnit, + }); + } + + // Projected Core unit → best-effort mapping for reviewable units. + if (!algorithmUnit.reviewable) { + throw new Error('This commit evolution unit is not reviewable.'); + } + const mapped = { + confidence: algorithmUnit.confidence === 'exact' ? 'exact' : 'high', + id: algorithmUnit.id, + kind: + algorithmUnit.kind === 'introduced' + ? 'added' + : algorithmUnit.kind === 'removed' + ? 'removed' + : algorithmUnit.kind === 'revised' + ? 'likely-revised' + : algorithmUnit.kind === 'rewritten-same-patch' + ? 'same-patch' + : algorithmUnit.kind === 'retained' + ? 'unchanged' + : algorithmUnit.kind === 'absorbed-into-base' + ? 'absorbed-into-base' + : 'unmatched-new', + reviewable: true, + ...(algorithmUnit.after ? { after: algorithmUnit.after } : {}), + ...(algorithmUnit.before ? { before: algorithmUnit.before } : {}), + ...(algorithmUnit.baseCommit ? { baseCommit: algorithmUnit.baseCommit } : {}), + }; + return gitlab.fetchGitLabVersionCommitUnitDiff({ + projectPath, + transport, + unit: mapped, + }); +}; + +module.exports = { + MR_BASE_VERSION_ID, + compareGitLabReviewVersions, + listGitLabReviewVersions, + loadGitLabVersionCommitUnitDiff, +}; diff --git a/electron/gitlab-history-bridge.cjs b/electron/gitlab-history-bridge.cjs new file mode 100644 index 00000000..2e193f0a --- /dev/null +++ b/electron/gitlab-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-gitlab (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitLabHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../gitlab/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitLabHistory, +}; From e64db1a4dff71917b6c955a1b169fb00d68fcfad Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:54:28 -0500 Subject: [PATCH 20/20] Load GitHub force-push history in local Codiff Implement GitHub head-timeline, comparison, evolution, and unit-diff loading over `gh` plus local Git objects, then dispatch review-history requests by provider. This keeps GitHub rewrite semantics separate from the GitLab merge-request backend while sharing the same Core review model. Connect local review history to the shared host Expose provider-neutral review-history and walkthrough-generation IPC to `LocalMergeRequestReviewHost`, materialize comparison and commit-unit files, and refresh the local repository state after review actions. Wire agent generation progress through Electron at the same integration boundary so the shared progress UI receives coherent host events. Document local review-history parity and follow-ups Record the local host architecture, authentication requirements, provider-specific terminology, and remaining review findings after the shared packages are adopted. Keeping these notes at the local MR tip makes deferred Electron and GitHub issues visible without leaking local concerns into the Codiff Web foundation docs. --- bin/walkthrough-guide.md | 2 + core/App.css | 216 ++++++- core/App.tsx | 6 +- core/SharedWalkthroughApp.tsx | 367 ++++++++--- core/__tests__/App-render.test.tsx | 31 +- .../LocalMergeRequestReviewHost.test.tsx | 461 +++++++++++++- core/__tests__/ReviewCodeView-scroll.test.tsx | 11 +- .../generate-review-walkthrough.test.ts | 42 ++ .../narrative-walkthrough-view.test.ts | 48 ++ core/__tests__/walkthrough-authoring.test.ts | 4 +- core/app/LocalMergeRequestReviewHost.tsx | 587 ++++++++++++++++-- core/app/components/CommitScopePanel.tsx | 11 +- core/app/components/ReviewCodeView.tsx | 54 +- core/app/components/Sidebar.tsx | 36 +- core/app/hooks/useAppWalkthrough.ts | 10 +- core/global.d.ts | 37 ++ core/index.ts | 15 + core/lib/generate-review-walkthrough.ts | 32 +- core/lib/narrative-walkthrough.ts | 19 + core/lib/review-strategy.ts | 338 ++++++++++ core/types.ts | 84 +++ docs/plan-local-parity.md | 57 +- .../__tests__/github-review-history.test.ts | 86 +++ .../__tests__/glab-gitlab-transport.test.ts | 23 +- .../__tests__/narrative-walkthrough.test.ts | 12 +- .../__tests__/pull-request-review.test.ts | 71 ++- .../__tests__/walkthrough-progress.test.ts | 13 +- .../generate-review-walkthrough-bridge.cjs | 39 ++ .../github-history/gh-github-transport.cjs | 205 ++++++ .../github-history/github-review-history.cjs | 285 +++++++++ electron/git-state/gitlab-review-history.cjs | 45 +- electron/git-state/glab-gitlab-transport.cjs | 9 +- electron/git-state/merge-request.cjs | 5 +- electron/git-state/pull-request.cjs | 27 +- electron/git-state/review-history.cjs | 120 ++++ electron/github-history-bridge.cjs | 23 + electron/main.cjs | 369 +++++++++++ electron/narrative-walkthrough-schema.cjs | 9 + electron/narrative-walkthrough.cjs | 21 +- electron/preload.cjs | 16 + electron/walkthrough-authoring-bridge.cjs | 4 +- electron/walkthrough-progress.cjs | 10 +- github/__tests__/history.test.ts | 74 +++ github/src/history.ts | 41 +- gitlab/__tests__/history-transport.test.ts | 102 ++- gitlab/fixtures/version-compare-cases.ts | 12 +- gitlab/src/history.ts | 224 ++++--- gitlab/src/review-strategy.ts | 350 +---------- gitlab/src/transport.ts | 44 +- package.json | 1 + pnpm-lock.yaml | 47 +- pnpm-workspace.yaml | 4 +- 52 files changed, 3938 insertions(+), 821 deletions(-) create mode 100644 core/lib/review-strategy.ts create mode 100644 electron/__tests__/github-review-history.test.ts create mode 100644 electron/generate-review-walkthrough-bridge.cjs create mode 100644 electron/git-state/github-history/gh-github-transport.cjs create mode 100644 electron/git-state/github-history/github-review-history.cjs create mode 100644 electron/git-state/review-history.cjs create mode 100644 electron/github-history-bridge.cjs diff --git a/bin/walkthrough-guide.md b/bin/walkthrough-guide.md index 9ea7e979..77fb66e2 100644 --- a/bin/walkthrough-guide.md +++ b/bin/walkthrough-guide.md @@ -67,6 +67,8 @@ choose. actually support. - If a PR/MR description is available, use it only as author intent and orientation. Do not copy it into the walkthrough JSON; the diff and hunk ids remain the source of truth. +- For a Codiff-provided commit or version unit digest, author only that unit. Codiff composes + successful unit walkthroughs in commit order and retains each unit's scoped files. ## hunkId format diff --git a/core/App.css b/core/App.css index 113690e7..5fed7b70 100644 --- a/core/App.css +++ b/core/App.css @@ -1317,6 +1317,13 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.version-picker-trigger > *:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .version-picker-positioner { /* Base UI renders this in a portal, outside the sidebar's clipping context. */ z-index: 1000; @@ -1379,7 +1386,9 @@ html[data-codiff-platform='darwin'] .sidebar { min-height: 24px; } -.version-comparison-status.error { color: var(--danger); } +.version-comparison-status.error { + color: var(--danger); +} .version-comparison-spinner { animation: version-comparison-spin 0.8s linear infinite; @@ -1390,12 +1399,25 @@ html[data-codiff-platform='darwin'] .sidebar { width: 12px; } -@keyframes version-comparison-spin { to { transform: rotate(360deg); } } +@keyframes version-comparison-spin { + to { + transform: rotate(360deg); + } +} -.version-picker-head { color: var(--accent, var(--sidebar-text)); font-weight: 700; } -.version-picker-additions { color: var(--diff-addition); } -.version-picker-deletions { color: var(--diff-deletion); } -.version-picker-timing { color: var(--muted); } +.version-picker-head { + color: var(--accent, var(--sidebar-text)); + font-weight: 700; +} +.version-picker-additions { + color: var(--diff-addition); +} +.version-picker-deletions { + color: var(--diff-deletion); +} +.version-picker-timing { + color: var(--muted); +} .version-base-movement, .version-commit-evolution, @@ -1409,18 +1431,28 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 7px; } -.version-base-movement a { color: inherit; font-family: var(--font-mono); } +.version-base-movement a { + color: inherit; + font-family: var(--font-mono); +} .version-base-movement-stat, .version-base-movement small, .version-commit-evolution small, -.version-walkthrough-structure small { color: var(--muted); } +.version-walkthrough-structure small { + color: var(--muted); +} .version-base-movement-stat { font: 11px/1.45 var(--font-sans); letter-spacing: 0; text-transform: none; } -.version-base-movement small { display: block; margin-top: 3px; } -.version-base-movement-commits { margin-top: 6px; } +.version-base-movement small { + display: block; + margin-top: 3px; +} +.version-base-movement-commits { + margin-top: 6px; +} .version-base-movement-commits > summary { color: var(--muted); cursor: pointer; @@ -1430,14 +1462,18 @@ html[data-codiff-platform='darwin'] .sidebar { text-transform: none; user-select: none; } -.version-base-movement-commits > summary::-webkit-details-marker { display: none; } +.version-base-movement-commits > summary::-webkit-details-marker { + display: none; +} .version-base-movement-commits > summary::before { content: '▸'; display: inline-block; margin-right: 4px; transition: transform 0.12s ease; } -.version-base-movement-commits[open] > summary::before { transform: rotate(90deg); } +.version-base-movement-commits[open] > summary::before { + transform: rotate(90deg); +} .version-base-movement-commit-list { margin-top: 5px; max-height: 220px; @@ -1456,9 +1492,15 @@ html[data-codiff-platform='darwin'] .sidebar { .version-base-movement-commit:hover { background: rgb(127 127 127 / 0.14); } -.version-commit-evolution > strong { display: block; margin-bottom: 4px; } +.version-commit-evolution > strong { + display: block; + margin-bottom: 4px; +} -.version-commit-evolution-list { display: grid; gap: 1px; } +.version-commit-evolution-list { + display: grid; + gap: 1px; +} .version-commit-unit { align-items: center; background: transparent; @@ -1469,17 +1511,32 @@ html[data-codiff-platform='darwin'] .sidebar { display: grid; font: 10px/1.35 var(--font-sans); gap: 5px; - grid-template-columns: 12px auto minmax(0, 1fr); + grid-template-columns: max-content max-content minmax(0, 1fr); padding: 4px; text-align: left; } -.version-commit-unit:disabled { cursor: default; } +.version-commit-unit:disabled { + cursor: default; +} .version-commit-unit:not(:disabled):hover, -.version-commit-unit[aria-pressed='true'] { background: rgb(127 127 127 / 0.14); } -.version-commit-unit code { font-size: 9px; white-space: nowrap; } -.version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.version-commit-unit.unchanged { opacity: 0.5; } -.version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } +.version-commit-unit[aria-pressed='true'] { + background: rgb(127 127 127 / 0.14); +} +.version-commit-unit code { + font-size: 9px; + white-space: nowrap; +} +.version-commit-unit > span:nth-child(3) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.version-commit-unit.unchanged { + opacity: 0.5; +} +.version-commit-unit.unchanged:not(:disabled):hover { + opacity: 0.8; +} .version-commit-kind-pill { align-items: center; background: color-mix(in srgb, currentColor 12%, transparent); @@ -1511,7 +1568,10 @@ html[data-codiff-platform='darwin'] .sidebar { .version-commit-kind-pill.unchanged { color: var(--muted); } -.version-commit-unit-block { display: grid; gap: 1px; } +.version-commit-unit-block { + display: grid; + gap: 1px; +} .version-commit-rebase-drivers { display: grid; gap: 1px; @@ -1528,9 +1588,18 @@ html[data-codiff-platform='darwin'] .sidebar { opacity: 0.88; } - -.version-unit-scope { align-items: center; display: flex; justify-content: space-between; } -.version-unit-scope button { background: transparent; border: 0; color: var(--accent, var(--sidebar-text)); cursor: pointer; font-size: 10px; } +.version-unit-scope { + align-items: center; + display: flex; + justify-content: space-between; +} +.version-unit-scope button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font-size: 10px; +} .version-tree-commit-scope { border-bottom: 1px solid var(--sidebar-border); display: grid; @@ -1594,17 +1663,35 @@ html[data-codiff-platform='darwin'] .sidebar { text-overflow: ellipsis; white-space: nowrap; } -.version-walkthrough-structure { display: grid; gap: 5px; margin-bottom: 10px; } -.version-walkthrough-structure label { align-items: center; display: flex; gap: 5px; } -.version-walkthrough-structure .sidebar-version-compare-button { margin-top: 3px; } +.version-walkthrough-structure { + display: grid; + gap: 5px; + margin-bottom: 10px; +} +.version-walkthrough-structure label { + align-items: center; + display: flex; + gap: 5px; +} +.version-walkthrough-structure .sidebar-version-compare-button { + margin-top: 3px; +} @media (max-width: 480px) { - .version-picker-popover { min-width: calc(100vw - 24px); } - .version-picker-option { gap: 3px; grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; } + .version-picker-popover { + min-width: calc(100vw - 24px); + } + .version-picker-option { + gap: 3px; + grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; + } .version-picker-option { grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto; } - .version-picker-timing { grid-column: 1 / -1; padding-left: 60px; } + .version-picker-timing { + grid-column: 1 / -1; + padding-left: 60px; + } } .sidebar-diff-scope-header { align-items: center; @@ -2250,6 +2337,46 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.sidebar-walkthrough-status-stack { + display: grid; + min-width: 0; + width: 100%; +} + +.walkthrough-generation-structure { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + gap: 5px; + min-width: 0; + padding: 8px 12px; +} + +.walkthrough-generation-structure strong, +.walkthrough-generation-structure small { + overflow-wrap: anywhere; +} + +.walkthrough-generation-structure strong { + color: var(--sidebar-text); + font: 700 11px/1.35 var(--font-sans); +} + +.walkthrough-generation-structure small { + color: var(--muted); + font: 10px/1.35 var(--font-mono); +} + +.walkthrough-generation-structure button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font: 11px/1.35 var(--font-sans); + justify-self: start; + padding: 0; + text-align: left; +} + .sidebar-walkthrough-status { border-bottom: 1px solid var(--sidebar-border); color: var(--muted); @@ -5203,6 +5330,31 @@ diffs-container .review-comment-thread { } @container sidebar (max-width: 260px) { + .version-comparison-header { + align-items: stretch; + grid-template-columns: minmax(0, 1fr); + } + + .diff-scope-control { + width: 100%; + } + + .version-picker-pair { + grid-template-columns: minmax(0, 1fr); + } + + .version-comparison-endpoint { + flex-wrap: wrap; + min-width: 0; + overflow-wrap: anywhere; + } + + .version-unit-scope { + align-items: flex-start; + display: grid; + gap: 4px; + } + .sidebar-mode-toggle { gap: 4px; grid-template-columns: minmax(42px, 0.75fr) minmax(82px, 1.35fr) minmax(58px, 0.9fr); @@ -5225,7 +5377,6 @@ diffs-container .review-comment-thread { } } - .walkthrough-regenerate-controls { align-items: stretch !important; display: grid !important; @@ -5246,7 +5397,6 @@ diffs-container .review-comment-thread { width: 100%; } - .walkthrough-structure-controls { align-items: stretch !important; display: grid !important; diff --git a/core/App.tsx b/core/App.tsx index 0f5ad074..f799cbcf 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1595,7 +1595,11 @@ export default function App() { ); } - if (shouldUseLocalMergeRequestHost(state.source)) { + if ( + shouldUseLocalMergeRequestHost(state.source) && + !launchOptions.walkthrough && + !launchOptions.walkthroughFile + ) { return ( >; + /** Shown in external-link tooltips (for example GitLab or GitHub). */ + providerLabel?: string; reviewStrategy?: MergeRequestReviewStrategySummary | null; selectedCommitSha?: string | null; settingsBar?: ReactNode; @@ -268,13 +271,18 @@ export type MergeRequestReviewAppProps = { versionCompareFromId?: string | null; versionCompareLoading?: boolean; versionCompareToId?: string | null; + /** Sidebar history section title. GitLab: Versions; GitHub: Head history. */ + versionHistoryLabel?: string; versionHistoryLoading?: boolean; + versionHistoryWarning?: string | null; versions?: ReadonlyArray; versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; walkthrough: NarrativeWalkthrough | null; walkthroughError?: string | null; walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; + /** Baseline scope label. GitLab: Whole MR; GitHub: Whole PR. */ + wholeDiffLabel?: string; }; const getCodeFontLineHeight = (size: number) => Math.round((size * 20) / 13); @@ -1402,9 +1410,14 @@ export type ReviewSurfaceProps = { versionCompareFromId?: string | null; versionCompareLoading?: boolean; versionCompareToId?: string | null; + /** Sidebar history section title. GitLab: Versions; GitHub: Head history. */ + versionHistoryLabel?: string; versionHistoryLoading?: boolean; + versionHistoryWarning?: string | null; versions?: ReadonlyArray; versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; + /** Baseline scope label. GitLab: Whole MR; GitHub: Whole PR. */ + wholeDiffLabel?: string; }; const shortRelativeTime = (value: string) => { @@ -1756,9 +1769,12 @@ export function ReviewSurface({ versionCompareFromId = null, versionCompareLoading = false, versionCompareToId = null, + versionHistoryLabel = 'Versions', versionHistoryLoading = false, + versionHistoryWarning = null, versions = [], versionWalkthroughStructure: versionWalkthroughStructureProp, + wholeDiffLabel = 'Whole MR', }: ReviewSurfaceProps) { const canComment = commenting?.canComment ?? Boolean(interactive); const deleteShare = useCallback(async () => { @@ -1775,6 +1791,7 @@ export function ReviewSurface({ } }, [onDeleteShare]); const submitReviewComment = commenting?.onSubmitComment ?? interactive?.onSubmitComment; + const canSubmitReviewComments = canComment && submitReviewComment != null; const submitGeneralDiscussion = commenting?.onSubmitGeneralComment ?? interactive?.onSubmitGeneralComment; const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; @@ -1825,10 +1842,12 @@ export function ReviewSurface({ const [versionSectionExpanded, setVersionSectionExpanded] = useState(true); useEffect(() => { - setSelectedVersionUnitIds(new Set()); - setVersionUnitFiles({}); - setVersionUnitLoadingIds(new Set()); - setVersionUnitErrors({}); + queueMicrotask(() => { + setSelectedVersionUnitIds(new Set()); + setVersionUnitFiles({}); + setVersionUnitLoadingIds(new Set()); + setVersionUnitErrors({}); + }); versionUnitScopeRef.current += 1; }, [versionCompare?.from.id, versionCompare?.to.id]); @@ -1837,10 +1856,14 @@ export function ReviewSurface({ return; } if (versionWalkthroughStructureProp == null) { - setVersionWalkthroughStructureState(versionCommitEvolution.recommendation.suggestedStructure); - onVersionWalkthroughStructureChange?.( - versionCommitEvolution.recommendation.suggestedStructure, - ); + queueMicrotask(() => { + setVersionWalkthroughStructureState( + versionCommitEvolution.recommendation.suggestedStructure, + ); + onVersionWalkthroughStructureChange?.( + versionCommitEvolution.recommendation.suggestedStructure, + ); + }); } }, [ onVersionWalkthroughStructureChange, @@ -1941,25 +1964,51 @@ export function ReviewSurface({ const [treeCommitDiffError, setTreeCommitDiffError] = useState(null); const [treeCommitDiffLoading, setTreeCommitDiffLoading] = useState(false); const commitLoadRequestRef = useRef(0); - // CBC walkthroughs are authored against unit-scoped commitFiles. Whole-diff - // walkthroughs are authored against the aggregate version comparison. + const walkthroughCommitShas = useMemo( + () => getWalkthroughCommitDiffShas(sharedWalkthrough), + [sharedWalkthrough], + ); + const isCommitByCommitWalkthrough = versionCompare + ? versionWalkthroughStructure === 'commit-by-commit' + : interactive?.reviewStrategy?.mode === 'commit-by-commit' || + sharedWalkthrough.chapters.some((chapter) => chapter.commit != null); + const legacyWalkthroughNeedsCommitDiffs = + isCommitByCommitWalkthrough && sharedWalkthrough.commitFiles == null; + const missingWalkthroughCommitShas = useMemo( + () => + legacyWalkthroughNeedsCommitDiffs + ? walkthroughCommitShas.filter((sha) => !commitFilesBySha[sha]) + : [], + [commitFilesBySha, legacyWalkthroughNeedsCommitDiffs, walkthroughCommitShas], + ); + // CBC walkthroughs are authored against the complete, unit-scoped + // commitFiles set. Legacy cached walkthroughs have no such set, so they + // remain empty until every chapter diff has loaded. const walkthroughFiles = useMemo( () => - versionCompare - ? resolveVersionWalkthroughFiles({ - commitFiles: sharedWalkthrough.commitFiles, - structure: versionWalkthroughStructure, - versionFiles: versionCompare.files, - }) - : walkthroughCommitSha - ? (commitFilesBySha[walkthroughCommitSha] ?? []) - : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], + legacyWalkthroughNeedsCommitDiffs + ? missingWalkthroughCommitShas.length === 0 + ? combineWalkthroughCommitFiles(walkthroughCommitShas, commitFilesBySha) + : [] + : versionCompare + ? resolveVersionWalkthroughFiles({ + commitFiles: sharedWalkthrough.commitFiles, + structure: versionWalkthroughStructure, + versionFiles: versionCompare.files, + }) + : isCommitByCommitWalkthrough + ? (sharedWalkthrough.commitFiles ?? []) + : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], [ commitFilesBySha, + isCommitByCommitWalkthrough, + legacyWalkthroughNeedsCommitDiffs, + missingWalkthroughCommitShas.length, sharedWalkthrough.commitFiles, snapshot.files, versionCompare, - walkthroughCommitSha, + versionWalkthroughStructure, + walkthroughCommitShas, ], ); const navigation = useNarrativeNavigation( @@ -2004,7 +2053,7 @@ export function ReviewSurface({ const commitSha = chapter?.commit?.sha ?? commits.find((commit) => chapter?.id.startsWith(`${commit.sha}:`))?.sha; - setWalkthroughCommitSha(commitSha ?? null); + queueMicrotask(() => setWalkthroughCommitSha(commitSha ?? null)); } }, [ navigation.index, @@ -2026,6 +2075,14 @@ export function ReviewSurface({ walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; }; } | null>(null); + const queuedWalkthroughGenerationOptionsRef = + useRef(null); + const [queuedReviewStructure, setQueuedReviewStructure] = useState< + 'commit-by-commit' | 'whole-diff' | null + >(null); + const [baselineWalkthroughStructureOverride, setBaselineWalkthroughStructureOverride] = useState< + 'commit-by-commit' | 'whole-diff' | null + >(null); const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< Readonly> @@ -2105,11 +2162,13 @@ export function ReviewSurface({ () => orderedAIReviews[0]?.id ?? null, ); useEffect(() => { - setSelectedAIReviewId((current) => - orderedAIReviews.some((review) => review.id === current) - ? current - : (orderedAIReviews[0]?.id ?? null), - ); + queueMicrotask(() => { + setSelectedAIReviewId((current) => + orderedAIReviews.some((review) => review.id === current) + ? current + : (orderedAIReviews[0]?.id ?? null), + ); + }); }, [orderedAIReviews]); const selectedAIReview = orderedAIReviews.find((review) => review.id === selectedAIReviewId) ?? @@ -2157,10 +2216,7 @@ export function ReviewSurface({ [commitFilesBySha, selectedTreeCommitShas], ); const versionCompareActive = - versionCompareEnabled === true || - versionCompare != null || - versionCompareLoading || - Boolean(versionCompareError); + versionCompareEnabled === true || versionCompare != null || versionCompareLoading; const versionCompareChangedPaths = useMemo(() => { const files = sidebarMode === 'walkthrough' && versionCompare @@ -2224,16 +2280,57 @@ export function ReviewSurface({ useEffect(() => { if (selectedCommitSha) { - setActiveCommitSha(selectedCommitSha); - setSelectedTreeCommitShas(new Set([selectedCommitSha])); + queueMicrotask(() => { + setActiveCommitSha(selectedCommitSha); + setSelectedTreeCommitShas(new Set([selectedCommitSha])); + }); } }, [selectedCommitSha]); const commitDiffTargetSha = - sidebarMode === 'walkthrough' || sidebarMode === 'commits' - ? (walkthroughCommitSha ?? activeCommitSha) - : null; + sidebarMode === 'commits' + ? activeCommitSha + : sidebarMode === 'walkthrough' && !legacyWalkthroughNeedsCommitDiffs + ? walkthroughCommitSha + : null; useEffect(() => { + if (sidebarMode === 'walkthrough' && legacyWalkthroughNeedsCommitDiffs) { + if (missingWalkthroughCommitShas.length === 0 || !interactive?.onLoadCommitDiff) { + return; + } + const requestId = commitLoadRequestRef.current + 1; + commitLoadRequestRef.current = requestId; + queueMicrotask(() => { + setCommitDiffLoading(true); + setCommitDiffError(null); + }); + void Promise.all( + missingWalkthroughCommitShas.map((sha) => + Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), + ), + ) + .then((results) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitFilesBySha((current) => ({ + ...current, + ...Object.fromEntries(results.map(({ files, sha }) => [sha, files])), + })); + }) + .catch((error: unknown) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitDiffError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (commitLoadRequestRef.current === requestId) { + setCommitDiffLoading(false); + } + }); + return; + } if ( (sidebarMode !== 'commits' && sidebarMode !== 'walkthrough') || !commitDiffTargetSha || @@ -2246,8 +2343,10 @@ export function ReviewSurface({ } const requestId = commitLoadRequestRef.current + 1; commitLoadRequestRef.current = requestId; - setCommitDiffLoading(true); - setCommitDiffError(null); + queueMicrotask(() => { + setCommitDiffLoading(true); + setCommitDiffError(null); + }); void Promise.resolve(interactive.onLoadCommitDiff(commitDiffTargetSha)) .then((files) => { if (commitLoadRequestRef.current !== requestId) { @@ -2267,7 +2366,14 @@ export function ReviewSurface({ setCommitDiffLoading(false); } }); - }, [commitDiffTargetSha, commitFilesBySha, interactive, sidebarMode]); + }, [ + commitDiffTargetSha, + commitFilesBySha, + interactive, + legacyWalkthroughNeedsCommitDiffs, + missingWalkthroughCommitShas, + sidebarMode, + ]); useEffect(() => { if ( @@ -2283,8 +2389,10 @@ export function ReviewSurface({ return; } let cancelled = false; - setTreeCommitDiffLoading(true); - setTreeCommitDiffError(null); + queueMicrotask(() => { + setTreeCommitDiffLoading(true); + setTreeCommitDiffError(null); + }); void Promise.all( missingShas.map((sha) => Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), @@ -2672,6 +2780,18 @@ export function ReviewSurface({ interactiveRef.current = interactive; }, [interactive]); + const beginWalkthroughGeneration = useCallback( + (options: typeof walkthroughGenerationOptionsRef.current) => { + walkthroughGenerationOptionsRef.current = options; + walkthroughRequestPendingRef.current = true; + walkthroughRequestForceRef.current = options?.force === true; + setWalkthroughRequestForce(options?.force === true); + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, + [], + ); + useEffect(() => { if (!walkthroughRequestPending || walkthroughRequestId === 0) { return; @@ -2690,11 +2810,17 @@ export function ReviewSurface({ walkthroughRequestForceRef.current = false; setWalkthroughRequestPending(false); setWalkthroughRequestForce(false); + const queuedOptions = queuedWalkthroughGenerationOptionsRef.current; + if (queuedOptions) { + queuedWalkthroughGenerationOptionsRef.current = null; + setQueuedReviewStructure(null); + queueMicrotask(() => beginWalkthroughGeneration(queuedOptions)); + } }); return () => { cancelled = true; }; - }, [walkthroughRequestId, walkthroughRequestPending]); + }, [beginWalkthroughGeneration, walkthroughRequestId, walkthroughRequestPending]); const startWalkthroughGeneration = useCallback( (options?: { @@ -2707,22 +2833,24 @@ export function ReviewSurface({ walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; }; }) => { + if (options?.reviewStructure) { + setBaselineWalkthroughStructureOverride(options.reviewStructure); + } if ( !interactive || interactive.walkthroughStatus === 'generating' || walkthroughRequestPendingRef.current ) { + if (interactive && options?.force && options.reviewStructure) { + queuedWalkthroughGenerationOptionsRef.current = options; + setQueuedReviewStructure(options.reviewStructure); + } return; } - walkthroughGenerationOptionsRef.current = options ?? null; - walkthroughRequestPendingRef.current = true; - walkthroughRequestForceRef.current = options?.force === true; - setWalkthroughRequestForce(options?.force === true); - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); + beginWalkthroughGeneration(options ?? null); }, - [interactive], + [beginWalkthroughGeneration, interactive], ); useEffect(() => { if (sidebarMode !== 'walkthrough') { @@ -2751,11 +2879,11 @@ export function ReviewSurface({ return; } lastAutoVersionWalkthroughKeyRef.current = key; - startWalkthroughGeneration(versionCompareWalkthroughOptions); + queueMicrotask(() => startWalkthroughGeneration(versionCompareWalkthroughOptions)); return; } if (interactive?.walkthroughStatus === 'idle') { - startWalkthroughGeneration(); + queueMicrotask(() => startWalkthroughGeneration()); } }, [ interactive?.walkthroughStatus, @@ -2981,6 +3109,7 @@ export function ReviewSurface({ activeSearchMatch: null, agentId: snapshot.walkthrough.agent, agentLabel: getAgentLabel(snapshot.walkthrough.agent), + canSubmitReviewComments, codeQualityFindings: snapshot.codeQualityFindings, collapsed, comments: reviewComments, @@ -3161,14 +3290,27 @@ export function ReviewSurface({ walkthroughReady && Boolean(versionCompare) && versionWalkthroughStructure === 'commit-by-commit' && + !legacyWalkthroughNeedsCommitDiffs && walkthroughFiles.length === 0; + const legacyWalkthroughDiffLoading = + walkthroughReady && + legacyWalkthroughNeedsCommitDiffs && + missingWalkthroughCommitShas.length > 0 && + commitDiffLoading; + const legacyWalkthroughDiffError = + walkthroughReady && + legacyWalkthroughNeedsCommitDiffs && + missingWalkthroughCommitShas.length > 0 && + commitDiffError; const walkthroughFailed = walkthroughStatus === 'failed'; const walkthroughIdle = walkthroughStatus === 'idle'; + const baselineWalkthroughStructure = + baselineWalkthroughStructureOverride ?? interactive?.reviewStrategy?.mode ?? 'whole-diff'; const walkthroughStructurePhrase = versionCompareActive ? versionWalkthroughStructure === 'commit-by-commit' ? 'commit-by-commit version' : 'whole-diff version' - : interactive?.reviewStrategy?.mode === 'commit-by-commit' + : baselineWalkthroughStructure === 'commit-by-commit' ? 'commit-by-commit' : 'whole-diff'; const computingVersionChanges = Boolean(versionCompareLoading); @@ -3224,7 +3366,7 @@ export function ReviewSurface({ startWalkthroughGeneration(options); }; const alternateReviewStructure = - interactive?.reviewStrategy?.mode === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; + baselineWalkthroughStructure === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); const reviewModes = [ { @@ -3403,7 +3545,7 @@ export function ReviewSurface({ onClick={() => interactive.onExitVersionCompare?.()} type="button" > - Whole MR + {wholeDiffLabel}
+ {!versionCompareActive && versionHistoryWarning ? ( +
{versionHistoryWarning}
+ ) : null} {versionSectionExpanded && versionCompareActive ? (
{versionHistoryLoading ? (
- Loading version history… + {`Loading ${versionHistoryLabel.toLowerCase()}…`}
) : versions.length >= 2 && interactive?.onVersionCompareRangeChange ? (
@@ -3836,7 +3981,7 @@ export function ReviewSurface({
{interactive.reviewStrategy.mode === 'commit-by-commit' ? 'Structured by commits' - : `Whole MR (${interactive.reviewStrategy.reason})`} + : `${wholeDiffLabel} (${interactive.reviewStrategy.reason})`}
) : null} {commits.map((commit) => { @@ -3924,9 +4069,9 @@ export function ReviewSurface({ {!versionCompareActive && interactive?.reviewStrategy ? (
- {interactive.reviewStrategy.mode === 'commit-by-commit' + {baselineWalkthroughStructure === 'commit-by-commit' ? 'Structured by commits' - : `Whole MR (${interactive.reviewStrategy.reason})`} + : `${wholeDiffLabel} (${interactive.reviewStrategy.reason})`} + {queuedReviewStructure ? ( + + Queued:{' '} + {queuedReviewStructure === 'commit-by-commit' + ? 'commit-by-commit' + : wholeDiffLabel} + ) : null} - - ) : ( - - )} +
+ ) : null} +
+ {walkthroughFailed || + (walkthroughIdle && !walkthroughBusy && !computingVersionChanges) ? ( + <> + {walkthroughStatusTitle} + {walkthroughStatusDescription ? ( + {walkthroughStatusDescription} + ) : null} + + ) : ( + + )} +
)} @@ -4210,19 +4389,13 @@ export function ReviewSurface({ ) : null}
- ) : walkthroughReady && - walkthroughCommitSha && - !commitFilesBySha[walkthroughCommitSha] && - commitDiffLoading ? ( + ) : legacyWalkthroughDiffLoading ? (
Loading commit walkthrough code…
- ) : walkthroughReady && - walkthroughCommitSha && - !commitFilesBySha[walkthroughCommitSha] && - commitDiffError ? ( + ) : legacyWalkthroughDiffError ? (
Unable to load commit walkthrough code -

{commitDiffError}

+

{legacyWalkthroughDiffError}

) : walkthroughReady ? ( @@ -4387,6 +4560,7 @@ export function MergeRequestReviewApp({ onVersionCompareRangeChange, onVersionWalkthroughStructureChange, preferences, + providerLabel = 'provider', reviewStrategy, selectedCommitSha, settingsBar, @@ -4402,13 +4576,16 @@ export function MergeRequestReviewApp({ versionCompareFromId, versionCompareLoading, versionCompareToId, + versionHistoryLabel = 'Versions', versionHistoryLoading, + versionHistoryWarning, versions, versionWalkthroughStructure, walkthrough, walkthroughError, walkthroughProgress, walkthroughStatus, + wholeDiffLabel = 'Whole MR', }: MergeRequestReviewAppProps) { const placeholderWalkthrough = useMemo( () => ({ @@ -4493,6 +4670,7 @@ export function MergeRequestReviewApp({ }} onModeChange={onModeChange} onVersionWalkthroughStructureChange={onVersionWalkthroughStructureChange} + providerLabel={providerLabel} selectedCommitSha={selectedCommitSha} settingsBar={settingsBar} snapshot={snapshot} @@ -4507,9 +4685,12 @@ export function MergeRequestReviewApp({ versionCompareFromId={versionCompareFromId} versionCompareLoading={versionCompareLoading} versionCompareToId={versionCompareToId} + versionHistoryLabel={versionHistoryLabel} versionHistoryLoading={versionHistoryLoading} + versionHistoryWarning={versionHistoryWarning} versions={versions} versionWalkthroughStructure={versionWalkthroughStructure} + wholeDiffLabel={wholeDiffLabel} /> ); } diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index 40e39383..44ecc3a0 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -146,6 +146,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'committed' as const, })), decreaseCodeFontSize: vi.fn(async () => {}), + generateReviewWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/Users/reviewer/.codex/skills/codiff', @@ -166,6 +170,11 @@ const createCodiffMock = (overrides: Partial = {}): Window['co email: 'reviewer@example.com', name: 'Reviewer', })), + getGitLabReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected GitLab version compare.'); + }), + getGitLabReviewVersions: vi.fn(async () => []), + getGitLabReviewVersionUnitDiff: vi.fn(async () => []), getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false, @@ -206,6 +215,12 @@ const createCodiffMock = (overrides: Partial = {}): Window['co root: '/repo', })), getRepositoryState: vi.fn(async () => repositoryState), + getReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected review version compare.'); + }), + getReviewVersions: vi.fn(async () => ({ versions: [], warning: null })), + getReviewVersionUnitDiff: vi.fn(async () => []), + getStoredReviewWalkthrough: vi.fn(async () => ({ status: 'missing' as const })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, @@ -411,7 +426,7 @@ test('stale persisted collapsed sidebar state does not hide the sidebar on launc false, ); expect(app.container.querySelector('.sidebar')).not.toBeNull(); - expect(app.container.querySelector('.sidebar [role="tablist"]')).toBeNull(); + expect(app.container.querySelector('.sidebar [role="tablist"]')).not.toBeNull(); }); const topBar = app.container.querySelector('.review-top-bar'); @@ -735,8 +750,8 @@ test('branch history keeps branch diff available after selecting uncommitted cha await waitFor(() => { expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - expect(findButton('All changes vs main')).toBeTruthy(); + expect(findButton('Uncommitted changes')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { @@ -745,11 +760,11 @@ test('branch history keeps branch diff available after selecting uncommitted cha await waitFor(() => { expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { - findButton('Committed only vs main')?.click(); + findButton('Branch diff vs main')?.click(); }); await waitFor(() => { @@ -813,7 +828,7 @@ test('repository reload restores branch diff scope after selecting uncommitted c await waitFor(() => { expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); expect(getRepositoryHistory).toHaveBeenCalledWith(expect.any(Number), branchSource); @@ -2909,11 +2924,11 @@ test('refreshing all changes re-resolves the branch snapshot', async () => { findButton('History')?.click(); }); await waitFor(() => { - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { - findButton('Committed only vs main')?.click(); + findButton('Branch diff vs main')?.click(); await new Promise((resolve) => setTimeout(resolve, 0)); }); diff --git a/core/__tests__/LocalMergeRequestReviewHost.test.tsx b/core/__tests__/LocalMergeRequestReviewHost.test.tsx index 3ff1ea94..335a5127 100644 --- a/core/__tests__/LocalMergeRequestReviewHost.test.tsx +++ b/core/__tests__/LocalMergeRequestReviewHost.test.tsx @@ -93,6 +93,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'committed' as const, })), decreaseCodeFontSize: vi.fn(async () => {}), + generateReviewWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/Users/reviewer/.codex/skills/codiff', @@ -113,6 +117,11 @@ const createCodiffMock = (overrides: Partial = {}): Window['co email: 'reviewer@example.com', name: 'Reviewer', })), + getGitLabReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected GitLab version compare.'); + }), + getGitLabReviewVersions: vi.fn(async () => []), + getGitLabReviewVersionUnitDiff: vi.fn(async () => []), getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false, @@ -170,6 +179,12 @@ const createCodiffMock = (overrides: Partial = {}): Window['co root: '/repo', })), getRepositoryState: vi.fn(async () => repositoryState), + getReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected review version compare.'); + }), + getReviewVersions: vi.fn(async () => ({ versions: [], warning: null })), + getReviewVersionUnitDiff: vi.fn(async () => []), + getStoredReviewWalkthrough: vi.fn(async () => ({ status: 'missing' as const })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, @@ -266,7 +281,7 @@ test('App mounts the shared merge-request review shell for pull-request sources' } }); -test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through local IPC', async () => { +test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through shared orchestration IPC', async () => { const walkthrough = { agent: 'codex', chapters: [], @@ -280,12 +295,12 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca version: 4, } satisfies NarrativeWalkthrough; - const getNarrativeWalkthrough = vi.fn(async () => ({ + const generateReviewWalkthrough = vi.fn(async () => ({ status: 'ready' as const, walkthrough, })); - window.codiff = createCodiffMock({ getNarrativeWalkthrough }); + window.codiff = createCodiffMock({ generateReviewWalkthrough }); const onHome = vi.fn(); const app = await renderReact( @@ -298,7 +313,7 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca try { await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledWith(pullRequestSource, {}); + expect(generateReviewWalkthrough).toHaveBeenCalled(); }); await waitFor(() => { // Empty-chapter walkthroughs still mark the surface ready. @@ -313,3 +328,441 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca await app.cleanup(); } }); + +test('walkthrough generation shows its structure and queues an override', async () => { + const walkthrough = { + agent: 'codex', + chapters: [], + focus: 'Focus.', + generatedAt: '2026-07-01T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature/local-host', root: '/repo' }, + source: pullRequestSource, + support: [], + title: 'Generated', + version: 4, + } satisfies NarrativeWalkthrough; + let resolveFirst: + | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) + | null = null; + const firstGeneration = new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>( + (resolve) => { + resolveFirst = resolve; + }, + ); + const generateReviewWalkthrough = vi + .fn() + .mockImplementationOnce(() => firstGeneration) + .mockResolvedValue({ status: 'ready' as const, walkthrough }); + window.codiff = createCodiffMock({ generateReviewWalkthrough }); + + const app = await renderReact( + {}} + state={repositoryState} + />, + ); + + try { + await waitFor(() => { + expect(app.container.textContent).toContain('Generating · Whole PR'); + }); + const switchButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Switch to commit-by-commit'), + ); + expect(switchButton).not.toBeUndefined(); + await act(async () => { + switchButton?.click(); + }); + expect(app.container.textContent).toContain('Queued: commit-by-commit'); + + await act(async () => { + resolveFirst?.({ status: 'ready', walkthrough }); + await firstGeneration; + }); + await waitFor(() => { + expect(generateReviewWalkthrough).toHaveBeenCalledTimes(2); + }); + expect(generateReviewWalkthrough.mock.calls[1]?.[0]).toMatchObject({ + force: true, + structure: 'units', + }); + } finally { + await app.cleanup(); + } +}); + +test('GitLab sources load review versions through IPC into the shared host', async () => { + const gitlabSource = { + description: '## Intent\n\nShip **MR** versions.', + host: 'gitlab.example.com', + number: 13, + projectPath: 'group/project', + provider: 'gitlab', + title: 'Local GitLab history', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/13', + } satisfies Extract; + + const versionOption = { + createdAt: '2026-01-02T00:00:00.000Z', + id: '2', + isHead: true, + number: 2, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'c'.repeat(40), + label: { kind: 'version' as const, text: 'v2' }, + }, + }, + }; + const baseOption = { + createdAt: '2026-01-01T00:00:00.000Z', + id: 'mr-base', + isHead: false, + number: 0, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'a'.repeat(40), + label: { kind: 'version' as const, text: 'MR base' }, + }, + }, + }; + const v1Option = { + createdAt: '2026-01-01T12:00:00.000Z', + id: '1', + isHead: false, + number: 1, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'b'.repeat(40), + label: { kind: 'version' as const, text: 'v1' }, + }, + }, + }; + + const getReviewVersions = vi.fn(async () => ({ + versions: [baseOption, v1Option, versionOption], + warning: null, + })); + const getReviewVersionCompare = vi.fn(async () => ({ + versionCommitEvolution: null, + versionCommitEvolutionError: null, + versionCompare: { + analysis: { + summary: { + addedLines: 1, + baseMoved: false, + commentsAffected: 0, + conflictFiles: 0, + deletedLines: 0, + empty: false, + filesChanged: 1, + intentionalFiles: 1, + noiseFiles: 0, + }, + }, + comparison: { + after: versionOption.range, + before: v1Option.range, + }, + files: [createChangedFile('src/app.ts')], + from: v1Option, + to: versionOption, + }, + warning: null, + })); + + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + ...repositoryState, + source: gitlabSource, + })), + getReviewVersionCompare, + getReviewVersions, + }); + + const app = await renderReact( + {}} + state={{ + ...repositoryState, + source: gitlabSource, + }} + />, + ); + + try { + await waitFor(() => { + expect(getReviewVersions).toHaveBeenCalledWith({ source: gitlabSource }); + }); + + await waitFor(() => { + expect(app.container.querySelector('.merge-request-shell')).not.toBeNull(); + }); + + const compareButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Compare versions'), + ); + expect(compareButton).not.toBeUndefined(); + + await act(async () => { + compareButton?.click(); + }); + + await waitFor(() => { + expect(getReviewVersionCompare).toHaveBeenCalledWith({ + fromId: '1', + source: gitlabSource, + toId: '2', + }); + }); + } finally { + await app.cleanup(); + } +}); + +test('GitHub sources load head history and use Compare heads copy', async () => { + const githubSource = { + description: '## Intent\n\nShip **PR** heads.', + number: 12, + owner: 'nkzw-tech', + provider: 'github', + repo: 'codiff', + title: 'Local GitHub history', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + } satisfies Extract; + + const headA = { + createdAt: '2026-01-01T00:00:00.000Z', + id: 'a'.repeat(40), + isHead: false, + number: 1, + range: { + base: { + commitId: '0'.repeat(40), + label: { kind: 'commit' as const, text: '0000000' }, + }, + head: { + commitId: 'a'.repeat(40), + label: { kind: 'version' as const, text: 'Head · aaaaaaa' }, + }, + }, + }; + const headB = { + createdAt: '2026-01-02T00:00:00.000Z', + id: 'b'.repeat(40), + isHead: true, + number: 2, + range: { + base: { + commitId: '0'.repeat(40), + label: { kind: 'commit' as const, text: '0000000' }, + }, + head: { + commitId: 'b'.repeat(40), + label: { kind: 'version' as const, text: 'Current head' }, + }, + }, + }; + + const getReviewVersions = vi.fn(async () => ({ + versions: [headA, headB], + warning: null, + })); + const unit = { + after: { + authoredAt: '2026-01-02T00:00:00.000Z', + authorName: 'Author', + parentIds: [headA.id], + sha: headB.id, + shortSha: headB.id.slice(0, 7), + subject: 'Update the implementation', + }, + confidence: 'exact' as const, + id: `introduced:${headB.id}`, + kind: 'introduced' as const, + order: 0, + reviewable: true as const, + }; + const getReviewVersionCompare = vi.fn(async () => ({ + versionCommitEvolution: { + recommendation: { + rationale: 'Review the changed commit.', + suggestedStructure: 'commit-by-commit' as const, + }, + summary: { + absorbedIntoBase: 0, + added: 1, + ambiguous: 0, + pairingCoverage: 1, + removed: 0, + retained: 0, + reviewable: 1, + revised: 0, + rewrittenSamePatch: 0, + }, + units: [unit], + }, + versionCommitEvolutionError: null, + versionCompare: { + analysis: { + summary: { + addedLines: 1, + baseMoved: false, + commentsAffected: 0, + conflictFiles: 0, + deletedLines: 0, + empty: false, + filesChanged: 1, + intentionalFiles: 1, + noiseFiles: 0, + }, + }, + comparison: { + after: headB.range, + before: headA.range, + }, + files: [createChangedFile('src/app.ts')], + from: headA, + to: headB, + }, + warning: null, + })); + const getReviewVersionUnitDiff = vi.fn(async () => [createChangedFile('src/unit.ts')]); + + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + ...repositoryState, + source: githubSource, + })), + getReviewVersionCompare, + getReviewVersions, + getReviewVersionUnitDiff, + }); + + const app = await renderReact( + {}} + state={{ + ...repositoryState, + source: githubSource, + }} + />, + ); + + try { + await waitFor(() => { + expect(getReviewVersions).toHaveBeenCalledWith({ source: githubSource }); + }); + await waitFor(() => { + expect(app.container.textContent).toContain('Whole PR'); + }); + const compareButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Compare heads'), + ); + expect(compareButton).not.toBeUndefined(); + await act(async () => { + compareButton?.click(); + }); + await waitFor(() => { + expect(getReviewVersionCompare).toHaveBeenCalledWith({ + fromId: headA.id, + source: githubSource, + toId: headB.id, + }); + }); + let unitButton: HTMLButtonElement | null = null; + await waitFor(() => { + unitButton = app.container.querySelector( + '.version-commit-unit.introduced', + ); + expect(unitButton).not.toBeNull(); + }); + await act(async () => { + unitButton?.click(); + }); + await waitFor(() => { + expect(getReviewVersionUnitDiff).toHaveBeenCalledWith({ + source: githubSource, + unit, + }); + }); + } finally { + await app.cleanup(); + } +}); + +test('history warnings remain baseline status instead of activating comparison mode', async () => { + window.codiff = createCodiffMock({ + getReviewVersions: vi.fn(async () => ({ + versions: [], + warning: 'Force-push timeline unavailable. Showing current head only.', + })), + }); + const app = await renderReact( + {}} state={repositoryState} />, + ); + + try { + await waitFor(() => { + expect(app.container.textContent).toContain('Force-push timeline unavailable'); + }); + const wholeButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Whole PR'), + ); + expect(wholeButton?.getAttribute('aria-pressed')).toBe('true'); + expect(app.container.querySelector('#version-comparison-body')).toBeNull(); + } finally { + await app.cleanup(); + } +}); + +test('manual refresh reloads review data and updates the freshness label', async () => { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const refreshedAt = Date.now(); + const getRepositoryState = vi.fn(async () => ({ + ...repositoryState, + generatedAt: refreshedAt, + })); + window.codiff = createCodiffMock({ getRepositoryState }); + const app = await renderReact( + {}} + state={{ ...repositoryState, generatedAt: sevenDaysAgo }} + />, + ); + + try { + let refreshButton: HTMLButtonElement | undefined; + await waitFor(() => { + refreshButton = Array.from(app.container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes('Refresh PR · updated 7d ago'), + ); + expect(refreshButton).not.toBeUndefined(); + }); + await act(async () => { + refreshButton?.click(); + }); + await waitFor(() => { + expect(getRepositoryState).toHaveBeenCalledWith(pullRequestSource); + expect(app.container.textContent).toContain('Refresh PR · updated just now'); + }); + } finally { + await app.cleanup(); + } +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 4c14d526..eccfb272 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -244,7 +244,7 @@ test('switching edited Markdown back to a diff flushes and refreshes it first', ({ textContent }) => textContent === 'View as Diff', ); expect(diffButton).not.toBeUndefined(); - expect(diffButton?.classList.contains('codiff-button')).toBe(true); + expect(diffButton?.classList.contains('codiff-markdown-button')).toBe(true); await act(async () => { diffButton?.click(); @@ -398,7 +398,7 @@ test('combined branch-only Markdown sections remain read-only', async () => { }); await waitFor(() => { - expect(container.querySelector('[aria-label="Preview plan.md"]')).not.toBeNull(); + expect(container.querySelector('.codiff-markdown-preview:not(.editable)')).not.toBeNull(); }); expect(container.querySelector('[aria-label="Edit plan.md"]')).toBeNull(); } finally { @@ -1360,6 +1360,7 @@ test('failed pull request comments keep their draft and can be retried', async ( const onUpdateComment = vi.fn(); const view = await renderReact( , ); @@ -1471,7 +1472,7 @@ test('file comments can be created for GitLab merge requests but not GitHub pull '.codiff-file-comment-button', ); expect(fileCommentButton).not.toBeNull(); - expect(fileCommentButton?.classList.contains('codiff-button')).toBe(true); + expect(fileCommentButton?.classList.contains('codiff-file-comment-button')).toBe(true); await act(async () => fileCommentButton?.click()); expect(onCreateComment).toHaveBeenCalledWith({ @@ -1632,7 +1633,7 @@ test('Enter on a focused review control is not converted into a hunk comment', a render(1); }); - const openButton = container.querySelector('.codiff-button'); + const openButton = container.querySelector('.codiff-open-button'); if (!openButton) { throw new Error('Expected the open file button.'); } diff --git a/core/__tests__/generate-review-walkthrough.test.ts b/core/__tests__/generate-review-walkthrough.test.ts index 1c72068a..96f89a6b 100644 --- a/core/__tests__/generate-review-walkthrough.test.ts +++ b/core/__tests__/generate-review-walkthrough.test.ts @@ -177,6 +177,48 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { ); }); +test('generateReviewWalkthrough authors ordinary commit units with commit context', async () => { + const commitUnit = { + commit: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: ['0'.repeat(40)], + sha: 'a'.repeat(40), + shortSha: 'aaaaaaa', + subject: 'Add the request path', + }, + id: `commit:${'a'.repeat(40)}`, + kind: 'commit' as const, + order: 0, + reviewable: true as const, + }; + const prompts: Array = []; + const result = await generateReviewWalkthrough({ + agent: 'codex', + plan: { structure: 'units', units: [commitUnit] }, + runModel: async ({ prompt }) => { + prompts.push(prompt); + return { draft }; + }, + states: { + byUnitId: { [commitUnit.id]: baseState }, + whole: baseState, + }, + }); + + expect(result.status).toBe('ready'); + if (result.status !== 'ready') { + return; + } + expect(prompts[0]).toContain('This is an independent walkthrough for commit'); + expect(prompts[0]).not.toContain('version comparison'); + expect(result.walkthrough.chapters[0]?.commit).toMatchObject({ + gitSha: commitUnit.commit.sha, + sha: commitUnit.commit.sha, + }); + expect(result.walkthrough.commitFiles).toEqual(baseState.files); +}); + test('generateReviewWalkthrough fails clearly without whole state', async () => { const result = await generateReviewWalkthrough({ agent: 'codex', diff --git a/core/__tests__/narrative-walkthrough-view.test.ts b/core/__tests__/narrative-walkthrough-view.test.ts index e2655470..750303e5 100644 --- a/core/__tests__/narrative-walkthrough-view.test.ts +++ b/core/__tests__/narrative-walkthrough-view.test.ts @@ -8,12 +8,14 @@ import { buildCommitModel, buildGenericCommitModel, buildWalkthroughView, + combineWalkthroughCommitFiles, focusChangedFileForHunks, formatWalkthroughFileLineRows, formatWalkthroughFileList, getCommitSelectionPaths, getUncoveredWalkthroughFileLineItems, getUncoveredWalkthroughFiles, + getWalkthroughCommitDiffShas, getWalkthroughRunNote, isWalkthroughCommittable, resolveWalkthroughHunkFile, @@ -664,6 +666,52 @@ test('resolveWalkthroughHunkFile requires exact anchor section', () => { expect(resolveWalkthroughHunkFile(testHunk, files)).toBeNull(); }); +test('commit walkthrough files preserve commit order and duplicate paths', () => { + const firstFile: ChangedFile = { + fingerprint: 'first', + path: 'src/shared.ts', + sections: [{ binary: false, id: 'first:section', kind: 'commit', patch: '+first' }], + status: 'modified', + }; + const secondFile: ChangedFile = { + fingerprint: 'second', + path: 'src/shared.ts', + sections: [{ binary: false, id: 'second:section', kind: 'commit', patch: '+second' }], + status: 'modified', + }; + const commitWalkthrough: NarrativeWalkthrough = { + ...walkthrough(), + chapters: [ + { + ...walkthrough().chapters[0], + commit: { gitSha: 'git-first', sha: 'first', shortSha: 'first', subject: 'First' }, + }, + { + ...walkthrough().chapters[1], + commit: { gitSha: 'git-second', sha: 'second', shortSha: 'second', subject: 'Second' }, + }, + ], + }; + + expect(getWalkthroughCommitDiffShas(commitWalkthrough)).toEqual(['git-first', 'git-second']); + expect( + combineWalkthroughCommitFiles(['git-first', 'git-second'], { + 'git-first': [firstFile], + 'git-second': [secondFile], + }), + ).toEqual([firstFile, secondFile]); + expect( + resolveWalkthroughHunkFile( + { + ...appHunk, + anchor: { ...appHunk.anchor, sectionId: 'second:section' }, + path: 'src/shared.ts', + }, + [firstFile, secondFile], + )?.file, + ).toBe(secondFile); +}); + const multiHunkFile = (): ChangedFile => ({ fingerprint: 'database-search', path: 'database_search.py', diff --git a/core/__tests__/walkthrough-authoring.test.ts b/core/__tests__/walkthrough-authoring.test.ts index e6ca889e..e745bef6 100644 --- a/core/__tests__/walkthrough-authoring.test.ts +++ b/core/__tests__/walkthrough-authoring.test.ts @@ -215,7 +215,7 @@ test('includes version-commit guidance and composes unit walkthroughs', () => { versionCommitContext: { after: { shortSha: 'bbbbbbb', subject: 'Later' }, before: { shortSha: 'aaaaaaa', subject: 'Earlier' }, - evolutionKind: 'revised', + evolutionKind: 'likely-revised', kind: 'version-commit', range: { fromLabel: 'v1', toLabel: 'v2' }, unitId: 'unit-1', @@ -287,7 +287,7 @@ test('scopes version-comparison Review focus to changes since the earlier versio entries: [ { context: { - after: { shortSha: 'bbbbbbb', subject: 'Later' }, + after: { sha: 'b'.repeat(40), shortSha: 'bbbbbbb', subject: 'Later' }, evolutionKind: 'added', kind: 'version-commit', range: { fromLabel: 'v1', toLabel: 'v2' }, diff --git a/core/app/LocalMergeRequestReviewHost.tsx b/core/app/LocalMergeRequestReviewHost.tsx index beeb4faf..0c332de3 100644 --- a/core/app/LocalMergeRequestReviewHost.tsx +++ b/core/app/LocalMergeRequestReviewHost.tsx @@ -1,17 +1,27 @@ +import { ArrowsClockwiseIcon as ArrowsClockwise } from '@phosphor-icons/react/ArrowsClockwise'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createDefaultConfig } from '../config/defaults.ts'; import type { CodiffConfig } from '../config/types.ts'; import { sortFiles } from '../lib/files.ts'; +import { + classifyReviewCommit, + classifyReviewStrategy, + orderCommitsTopologically, +} from '../lib/review-strategy.ts'; import { getSourceLabel } from '../lib/source.ts'; import { MergeRequestReviewApp, type MergeRequestCommitListEntry, type MergeRequestReviewMode, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, type MergeRequestWalkthroughStatus, } from '../SharedWalkthroughApp.tsx'; import type { ChangedFile, CodiffPreferences, + GenerateLocalReviewWalkthroughRequest, GitIdentity, HistoryEntry, NarrativeWalkthrough, @@ -19,8 +29,12 @@ import type { PullRequestReviewComment, PullRequestReviewEvent, RepositoryState, + WalkthroughGenerationProgress, + ReviewEvolutionUnit, + ReviewStrategySummary, ReviewSource, } from '../types.ts'; +import { Button } from './components/Button.tsx'; const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ ...settings, @@ -31,15 +45,45 @@ const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); const toCommitListEntries = ( entries: ReadonlyArray, ): ReadonlyArray => - entries - .filter((entry) => entry.scope !== 'base') - .map((entry) => ({ - authoredAt: new Date(entry.committedAt).toISOString(), - authorName: entry.author, - sha: entry.ref, - shortSha: entry.ref.slice(0, 7), - subject: entry.subject, - })); + orderCommitsTopologically( + entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => + classifyReviewCommit({ + authoredDate: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + message: entry.subject, + parentIds: entry.parents, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + title: entry.subject, + }), + ), + ).map((entry) => ({ + authoredAt: entry.authoredAt, + authorName: entry.authorName, + role: entry.role, + sha: entry.sha, + shortSha: entry.shortSha, + subject: entry.subject, + webUrl: entry.webUrl, + })); + +const shortUpdatedAge = (timestamp: number, now: number) => { + const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); + if (seconds < 60) { + return 'just now'; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + return `${Math.floor(hours / 24)}d ago`; +}; const getPullRequestTitle = (state: RepositoryState) => { if (state.source.type !== 'pull-request') { @@ -99,15 +143,92 @@ function LocalMergeRequestReviewSession({ const state = liveState ?? sortRepositoryState(initialState); const stateRef = useRef(state); const [commits, setCommits] = useState>([]); + const [reviewStrategy, setReviewStrategy] = useState(null); const [walkthrough, setWalkthrough] = useState(null); - const [walkthroughStatus, setWalkthroughStatus] = - useState('idle'); + const [walkthroughStatus, setWalkthroughStatus] = useState('idle'); const [walkthroughError, setWalkthroughError] = useState(null); + const [walkthroughProgress, setWalkthroughProgress] = + useState(null); const [configPreferences, setConfigPreferences] = useState(defaultPreferences); const [mode, setMode] = useState(initialMode); const [selectedCommitSha, setSelectedCommitSha] = useState(null); + const [versions, setVersions] = useState>([]); + const [historyWarning, setHistoryWarning] = useState(null); + const [versionHistoryLoading, setVersionHistoryLoading] = useState(() => { + if (initialState.source.type !== 'pull-request') { + return false; + } + const provider = initialState.source.provider; + return provider === 'gitlab' || provider === 'github' || provider == null; + }); + const [versionCompare, setVersionCompare] = useState(null); + const [versionCompareLoading, setVersionCompareLoading] = useState(false); + const [versionCompareError, setVersionCompareError] = useState(null); + const [versionCompareFromId, setVersionCompareFromId] = useState(null); + const [versionCompareToId, setVersionCompareToId] = useState(null); + const [versionCommitEvolution, setVersionCommitEvolution] = + useState(null); + const [versionCommitEvolutionLoading, setVersionCommitEvolutionLoading] = useState(false); + const [versionCommitEvolutionError, setVersionCommitEvolutionError] = useState( + null, + ); + const [versionWalkthroughStructure, setVersionWalkthroughStructure] = useState< + 'commit-by-commit' | 'whole-diff' | undefined + >(undefined); + const [lastRefreshAt, setLastRefreshAt] = useState(initialState.generatedAt); + const [refreshNow, setRefreshNow] = useState(null); + const [refreshError, setRefreshError] = useState(null); + const [refreshing, setRefreshing] = useState(false); const commitDiffCacheRef = useRef>>(new Map()); const walkthroughRequestRef = useRef(0); + const versionCompareRequestRef = useRef(0); + const compareCacheRef = useRef< + Map< + string, + { + versionCommitEvolution: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError: string | null; + versionCompare: MergeRequestVersionCompareView; + warning?: string | null; + } + > + >(new Map()); + + useEffect(() => { + const update = () => setRefreshNow(Date.now()); + const timeout = window.setTimeout(update, 0); + const interval = window.setInterval(update, 60_000); + return () => { + window.clearInterval(interval); + window.clearTimeout(timeout); + }; + }, []); + + const applyHistory = useCallback((history: { entries: ReadonlyArray }) => { + const nextCommits = toCommitListEntries(history.entries); + setCommits(nextCommits); + const source = stateRef.current.source; + const strategy = classifyReviewStrategy({ + commits: history.entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => ({ + authoredDate: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + message: entry.subject, + parentIds: entry.parents, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + title: entry.subject, + })), + description: source.type === 'pull-request' ? source.description : undefined, + title: source.type === 'pull-request' ? source.title : undefined, + }); + setReviewStrategy({ + confidence: strategy.confidence, + mode: strategy.mode === 'whole-mr' ? 'whole-diff' : 'commit-by-commit', + reason: strategy.reason, + }); + }, []); useEffect(() => { stateRef.current = state; @@ -132,6 +253,16 @@ function LocalMergeRequestReviewSession({ }; }, []); + useEffect( + () => + window.codiff.onWalkthroughProgress((event) => { + if (event.generation) { + setWalkthroughProgress(event.generation); + } + }), + [], + ); + useEffect(() => { if (state.source.type !== 'pull-request') { return; @@ -145,7 +276,7 @@ function LocalMergeRequestReviewSession({ if (canceled) { return; } - setCommits(toCommitListEntries(history.entries)); + applyHistory(history); }; loadCommits().catch(() => { @@ -154,6 +285,42 @@ function LocalMergeRequestReviewSession({ } }); + return () => { + canceled = true; + }; + }, [applyHistory, state.source]); + + useEffect(() => { + if (state.source.type !== 'pull-request') { + return; + } + const provider = state.source.provider; + if (provider !== 'gitlab' && provider !== 'github' && provider != null) { + return; + } + + let canceled = false; + const source = state.source; + + window.codiff + .getReviewVersions({ source }) + .then((result) => { + if (canceled) { + return; + } + setVersions(result.versions); + setHistoryWarning(result.warning ?? null); + setVersionHistoryLoading(false); + }) + .catch((error: unknown) => { + if (canceled) { + return; + } + setVersions([]); + setHistoryWarning(error instanceof Error ? error.message : String(error)); + setVersionHistoryLoading(false); + }); + return () => { canceled = true; }; @@ -170,8 +337,72 @@ function LocalMergeRequestReviewSession({ setLiveState(ordered); commitDiffCacheRef.current.clear(); setSelectedCommitSha(null); + setLastRefreshAt(ordered.generatedAt); + setRefreshNow(Date.now()); }, []); + const refreshRemoteReview = useCallback(async () => { + const current = stateRef.current; + if (current.source.type !== 'pull-request' || refreshing) { + return; + } + setRefreshing(true); + setRefreshError(null); + try { + const [nextState, history, versionResult] = await Promise.all([ + window.codiff.getRepositoryState(current.source), + window.codiff.getRepositoryHistory(200, current.source), + window.codiff.getReviewVersions({ source: current.source }), + ]); + const ordered = sortRepositoryState(nextState); + let comparison: Awaited> | null = + null; + if ( + versionCompareFromId && + versionCompareToId && + versionResult.versions.some((version) => version.id === versionCompareFromId) && + versionResult.versions.some((version) => version.id === versionCompareToId) + ) { + comparison = await window.codiff.getReviewVersionCompare({ + fromId: versionCompareFromId, + source: ordered.source as Extract, + toId: versionCompareToId, + }); + } + + stateRef.current = ordered; + setLiveState(ordered); + applyHistory(history); + setVersions(versionResult.versions); + setHistoryWarning(versionResult.warning ?? null); + compareCacheRef.current.clear(); + commitDiffCacheRef.current.clear(); + setSelectedCommitSha(null); + if (comparison) { + setVersionCompare(comparison.versionCompare); + setVersionCommitEvolution(comparison.versionCommitEvolution); + setVersionCommitEvolutionError(comparison.versionCommitEvolutionError); + } else if (versionCompareFromId || versionCompareToId) { + versionCompareRequestRef.current += 1; + setVersionCompare(null); + setVersionCompareFromId(null); + setVersionCompareToId(null); + setVersionCompareError(null); + setVersionCompareLoading(false); + setVersionCommitEvolution(null); + setVersionCommitEvolutionError(null); + setVersionCommitEvolutionLoading(false); + setVersionWalkthroughStructure(undefined); + } + setLastRefreshAt(ordered.generatedAt); + setRefreshNow(Date.now()); + } catch (error: unknown) { + setRefreshError(error instanceof Error ? error.message : String(error)); + } finally { + setRefreshing(false); + } + }, [applyHistory, refreshing, versionCompareFromId, versionCompareToId]); + useEffect(() => window.codiff.onRefreshRequest(() => void refreshState()), [refreshState]); const onSubmitComment = useCallback( @@ -239,6 +470,188 @@ function LocalMergeRequestReviewSession({ return files; }, []); + const onLoadVersionCommitDiff = useCallback( + async (unitId: string) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return []; + } + const unit = versionCommitEvolution?.units.find((candidate) => candidate.id === unitId); + if (!unit) { + throw new Error(`Unknown version commit unit: ${unitId}`); + } + return window.codiff.getReviewVersionUnitDiff({ + source: current.source, + unit: unit as ReviewEvolutionUnit, + }); + }, + [versionCommitEvolution], + ); + + const loadVersionCompare = useCallback( + async ( + fromId: string, + toId: string, + endpoints?: { + from?: Parameters[0]['from']; + to?: Parameters[0]['to']; + }, + ) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + if (!fromId || !toId || fromId === toId) { + return; + } + + const requestId = ++versionCompareRequestRef.current; + setVersionCompareFromId(fromId); + setVersionCompareToId(toId); + setVersionCompareError(null); + setVersionCommitEvolutionError(null); + + const cacheKey = `${current.source.url}:${fromId}:${toId}:${JSON.stringify(endpoints ?? null)}`; + const cached = compareCacheRef.current.get(cacheKey); + if (cached) { + setVersionCompare(cached.versionCompare); + setVersionCommitEvolution(cached.versionCommitEvolution); + setVersionCommitEvolutionError(cached.versionCommitEvolutionError); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + if (cached.versionCommitEvolution?.recommendation.suggestedStructure) { + setVersionWalkthroughStructure( + cached.versionCommitEvolution.recommendation.suggestedStructure, + ); + } + if (cached.warning) { + setHistoryWarning(cached.warning); + } + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + return; + } + + setVersionCompareLoading(true); + setVersionCommitEvolutionLoading(true); + + try { + const result = await window.codiff.getReviewVersionCompare({ + ...(endpoints?.from ? { from: endpoints.from } : { fromId }), + source: current.source, + ...(endpoints?.to ? { to: endpoints.to } : { toId }), + }); + if (requestId !== versionCompareRequestRef.current) { + return; + } + compareCacheRef.current.set(cacheKey, result); + setVersionCompareFromId(result.versionCompare.from.id); + setVersionCompareToId(result.versionCompare.to.id); + setVersionCompare(result.versionCompare); + setVersionCommitEvolution(result.versionCommitEvolution); + setVersionCommitEvolutionError(result.versionCommitEvolutionError); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + if (result.warning) { + setHistoryWarning(result.warning); + } + if (result.versionCommitEvolution?.recommendation.suggestedStructure) { + setVersionWalkthroughStructure( + result.versionCommitEvolution.recommendation.suggestedStructure, + ); + } + // Clear baseline walkthrough so compare mode can regenerate for the range. + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + } catch (error: unknown) { + if (requestId !== versionCompareRequestRef.current) { + return; + } + setVersionCompare(null); + setVersionCommitEvolution(null); + setVersionCompareError(error instanceof Error ? error.message : String(error)); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + } + }, + [], + ); + + const onOpenVersionCompare = useCallback( + (options?: { commentId?: string }) => { + if (versions.length < 2) { + return; + } + const comment = options?.commentId + ? stateRef.current.reviewComments?.find((candidate) => candidate.id === options.commentId) + : null; + const currentVersion = versions.at(-1); + const commentVersion = comment + ? versions.find( + (version) => + version.id === comment.versionId || + version.range.head.commitId === + (comment.positionIdentity?.headSha ?? comment.versionHeadSha), + ) + : null; + if (comment?.positionIdentity && currentVersion) { + const fallbackFrom = + commentVersion?.id === currentVersion.id + ? (versions.at(-2)?.id ?? commentVersion.id) + : (commentVersion?.id ?? comment.positionIdentity.headSha); + if (fallbackFrom && fallbackFrom !== currentVersion.id) { + void loadVersionCompare(fallbackFrom, currentVersion.id, { + from: { + ...comment.positionIdentity, + commentId: comment.id, + kind: 'comment-position', + }, + to: { id: currentVersion.id, kind: 'version' }, + }); + return; + } + } + if (commentVersion && currentVersion && commentVersion.id !== currentVersion.id) { + void loadVersionCompare(commentVersion.id, currentVersion.id); + return; + } + // Default: previous version → current head (newest last in our list). + const toId = versions.at(-1)?.id; + const fromId = versions.at(-2)?.id; + if (!fromId || !toId) { + return; + } + void loadVersionCompare(fromId, toId); + }, + [loadVersionCompare, versions], + ); + + const onExitVersionCompare = useCallback(() => { + versionCompareRequestRef.current += 1; + setVersionCompare(null); + setVersionCompareFromId(null); + setVersionCompareToId(null); + setVersionCompareError(null); + setVersionCompareLoading(false); + setVersionCommitEvolution(null); + setVersionCommitEvolutionError(null); + setVersionCommitEvolutionLoading(false); + setVersionWalkthroughStructure(undefined); + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + setWalkthroughProgress(null); + }, []); + + const onVersionCompareRangeChange = useCallback( + (fromId: string, toId: string) => { + void loadVersionCompare(fromId, toId); + }, + [loadVersionCompare], + ); + const onGenerateWalkthrough = useCallback( async (options?: { force?: boolean; @@ -254,40 +667,76 @@ function LocalMergeRequestReviewSession({ if (current.source.type !== 'pull-request') { return; } - // Version-scoped / unit generation lands with history wiring; baseline whole-diff only. - if (options?.versionCompare || options?.unitId) { - setWalkthroughStatus('failed'); - setWalkthroughError( - 'Version compare and unit walkthroughs are not wired in local Codiff yet.', - ); - return; - } - if (current.files.length === 0) { - setWalkthrough(null); - setWalkthroughStatus('idle'); - setWalkthroughError(null); - return; - } const requestId = ++walkthroughRequestRef.current; - setWalkthroughStatus('generating'); setWalkthroughError(null); try { - const result = await window.codiff.getNarrativeWalkthrough( - current.source, - options?.force ? { force: true } : {}, - ); + const structure: NonNullable = + options?.unitId || options?.reviewStructure === 'commit-by-commit' + ? 'units' + : options?.reviewStructure === 'whole-diff' + ? 'whole-diff' + : options?.versionCompare?.walkthroughStructure === 'commit-by-commit' + ? 'units' + : options?.versionCompare?.walkthroughStructure === 'whole-diff' + ? 'whole-diff' + : reviewStrategy?.mode === 'commit-by-commit' + ? 'units' + : reviewStrategy?.mode === 'whole-diff' + ? 'whole-diff' + : 'auto'; + const versionCompareRequest = options?.versionCompare + ? { + fromId: options.versionCompare.fromId, + toId: options.versionCompare.toId, + } + : versionCompareFromId && versionCompareToId + ? { + fromId: versionCompareFromId, + toId: versionCompareToId, + } + : undefined; + const request = { + source: current.source, + structure, + ...(versionCompareRequest ? { versionCompare: versionCompareRequest } : {}), + } satisfies Omit; + if (!options?.force && !options?.unitId) { + const stored = await window.codiff.getStoredReviewWalkthrough(request); + if (requestId !== walkthroughRequestRef.current) { + return; + } + if (stored.status === 'ready') { + setWalkthrough(stored.walkthrough); + setWalkthroughStatus('ready'); + setWalkthroughProgress(null); + return; + } + } + + setWalkthroughStatus('generating'); + setWalkthroughProgress({ + phase: 'preparing', + summary: 'Starting generation.', + }); + const result = await window.codiff.generateReviewWalkthrough({ + ...request, + ...(options?.force ? { force: true } : {}), + ...(options?.unitId ? { unitId: options.unitId } : {}), + }); if (requestId !== walkthroughRequestRef.current) { return; } if (result.status === 'ready') { setWalkthrough(result.walkthrough); setWalkthroughStatus('ready'); + setWalkthroughProgress(null); setWalkthroughError(null); return; } setWalkthrough(null); setWalkthroughStatus('failed'); + setWalkthroughProgress(null); setWalkthroughError(result.reason); } catch (error: unknown) { if (requestId !== walkthroughRequestRef.current) { @@ -295,14 +744,32 @@ function LocalMergeRequestReviewSession({ } setWalkthrough(null); setWalkthroughStatus('failed'); + setWalkthroughProgress(null); setWalkthroughError(error instanceof Error ? error.message : String(error)); } }, - [], + [reviewStrategy, versionCompareFromId, versionCompareToId], ); const title = useMemo(() => getPullRequestTitle(state), [state]); const externalUrl = state.source.type === 'pull-request' ? state.source.url : ''; + const supportsReviewHistory = + state.source.type === 'pull-request' && + (state.source.provider === 'gitlab' || + state.source.provider === 'github' || + state.source.provider == null); + const providerLabel = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'GitLab' + : 'GitHub'; + const versionHistoryTitle = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'Versions' + : 'Head history'; + const wholeDiffLabel = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'Whole MR' + : 'Whole PR'; const resolvedPreferences = useMemo( () => ({ @@ -326,33 +793,65 @@ function LocalMergeRequestReviewSession({ externalUrl={externalUrl} gitIdentity={gitIdentity} initialMode={mode} - onExitVersionCompare={() => { - // History wiring lands in a later plan step. - }} + onExitVersionCompare={supportsReviewHistory ? onExitVersionCompare : undefined} onGenerateWalkthrough={onGenerateWalkthrough} onHome={onHome} onLoadCommitDiff={onLoadCommitDiff} + onLoadVersionCommitDiff={supportsReviewHistory ? onLoadVersionCommitDiff : undefined} onModeChange={setMode} - onOpenVersionCompare={() => { - // History wiring lands in a later plan step. - }} + onOpenVersionCompare={supportsReviewHistory ? onOpenVersionCompare : undefined} onSubmitComment={onSubmitComment} onSubmitGeneralComment={onSubmitGeneralComment} onSubmitReview={onSubmitReview} onUpdateComment={onUpdateComment} onUpdateGeneralComment={onUpdateGeneralComment} - onVersionCompareRangeChange={() => { - // History wiring lands in a later plan step. - }} + onVersionCompareRangeChange={supportsReviewHistory ? onVersionCompareRangeChange : undefined} + onVersionWalkthroughStructureChange={setVersionWalkthroughStructure} preferences={resolvedPreferences} + providerLabel={providerLabel} + reviewStrategy={reviewStrategy} selectedCommitSha={selectedCommitSha} + settingsBar={ + + } state={state} title={title} - versionHistoryLoading={false} - versions={[]} + versionCommitEvolution={versionCommitEvolution} + versionCommitEvolutionError={versionCommitEvolutionError} + versionCommitEvolutionLoading={versionCommitEvolutionLoading} + versionCompare={versionCompare} + versionCompareEnabled={Boolean( + versionCompareFromId || versionCompareToId || versionCompareLoading || versionCompare, + )} + versionCompareError={versionCompareError} + versionCompareFromId={versionCompareFromId} + versionCompareLoading={versionCompareLoading} + versionCompareToId={versionCompareToId} + versionHistoryLabel={versionHistoryTitle} + versionHistoryLoading={versionHistoryLoading} + versionHistoryWarning={historyWarning} + versions={versions} + versionWalkthroughStructure={versionWalkthroughStructure} walkthrough={walkthrough} walkthroughError={walkthroughError} + walkthroughProgress={walkthroughProgress} walkthroughStatus={walkthroughStatus} + wholeDiffLabel={wholeDiffLabel} /> ); } @@ -360,7 +859,7 @@ function LocalMergeRequestReviewSession({ /** * Desktop host adapter for pull-request / merge-request sources. * Feeds Core `MergeRequestReviewApp` with local IPC-backed data and actions. - * Version compare / evolution props stay empty until later plan steps. + * GitLab sources also load package-backed version history / compare / evolution. */ export function LocalMergeRequestReviewHost(props: LocalMergeRequestReviewHostProps) { const sourceKey = getSourceIdentityKey(props.state.source); diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx index 80913391..835c3e8b 100644 --- a/core/app/components/CommitScopePanel.tsx +++ b/core/app/components/CommitScopePanel.tsx @@ -8,6 +8,13 @@ import type { } from '../../SharedWalkthroughApp.tsx'; import { CommitRefTooltip } from './CommitRefTooltip.tsx'; +const clickedLink = (target: EventTarget | null): boolean => + typeof target === 'object' && + target != null && + 'closest' in target && + typeof target.closest === 'function' && + target.closest('a') != null; + export type CommitScopePanelProps = { commits: ReadonlyArray; mode: 'merge-request' | 'version-compare'; @@ -81,7 +88,7 @@ export function CommitScopePanel({