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 a3fc0d5c..5fed7b70 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,245 +1269,1112 @@ html[data-codiff-platform='darwin'] .sidebar { justify-content: center; padding: 0; text-decoration: none; - width: 28px; -} - -.merge-request-nav-button:not(.merge-request-home-button):hover { - background: rgb(127 127 127 / 0.12); - color: var(--sidebar-text); -} - -.merge-request-nav-icon { - border-radius: 5px; - display: block; - height: 20px; - width: 20px; + min-width: 28px; } - -.merge-request-walkthrough-status { - color: var(--muted); - font: 13px/1.4 var(--font-sans); - padding: 18px 14px; +.sidebar-version-compare-button { + padding-inline: 8px; + width: auto; } -.sidebar-search-row { +.version-comparison-section { display: grid; - flex: none; gap: 8px; - padding: 10px 8px; -} - -.sidebar-settings-bar { - align-items: center; - border-top: 1px solid var(--sidebar-border); - display: flex; - flex: none; - height: 38px; - min-height: 38px; - padding: 4px 8px; -} - -.sidebar-settings-line-count { - font: 600 11px/1 var(--font-mono); - margin-left: auto; + min-width: 0; + padding: 8px 12px; } -.sidebar-search { - -webkit-app-region: no-drag; - background: rgb(127 127 127 / 0.1); - border: 0; - border-radius: 15px; - color: var(--sidebar-text); - corner-shape: squircle; - font: 13px/1.35 var(--font-sans); - height: 30px; - outline: none; - padding: 0 11px; - width: 100%; +.version-picker-pair { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); } -.sidebar-search:focus { - box-shadow: 0 0 0 2px rgb(61 135 245 / 0.32); +.version-picker { + min-width: 0; + position: relative; } -.sidebar-search::placeholder { +.version-picker-label { color: var(--muted); + display: block; + font: 10px/1.35 var(--font-mono); + margin-bottom: 3px; + text-transform: uppercase; } -.sidebar-total-row { +.version-picker-trigger { align-items: center; - border-top: 1px solid var(--sidebar-border); - color: var(--muted); + 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; - flex: none; - font: 600 11px/1 var(--font-sans); - gap: 10px; + font: 12px/1.35 var(--font-mono); justify-content: space-between; - min-height: 38px; - padding: 8px; -} - -.sidebar-total-summary { - align-items: center; - display: inline-flex; - gap: 8px; min-width: 0; + padding: 5px 7px; + width: 100%; } -.sidebar-total-line-count { - display: inline-flex; - flex: none; - font-family: var(--font-mono); - gap: 8px; +.version-picker-trigger > *:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -.sidebar-commit-button { - flex: none; - color: var(--sidebar-text); -} - -.file-tree-shell, -.file-tree { - flex: 1; - display: block; - min-height: 0; +.version-picker-positioner { + /* Base UI renders this in a portal, outside the sidebar's clipping context. */ + z-index: 1000; } -.file-tree { - height: 100%; +.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; } -.history-list { - display: flex; - flex-direction: column; - flex: 1; - gap: 2px; - min-height: 0; - overflow: auto; - padding: 8px 6px; +.version-picker-popover [role='listbox'] { + display: grid; } -.history-entry { - -webkit-app-region: no-drag; +.version-picker-option { + align-items: center; background: transparent; border: 0; - border-radius: 8px; - color: var(--text); - corner-shape: squircle; + border-radius: 5px; + color: var(--sidebar-text); cursor: pointer; display: grid; - column-gap: 8px; - grid-template-columns: 72px minmax(0, 1fr); - padding: 3px 5px; - row-gap: 2px; + 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; - width: 100%; + white-space: nowrap; } -.history-entry:hover, -.history-entry.selected { - background: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); +.version-picker-option:hover, +.version-picker-option[data-highlighted], +.version-picker-option[data-selected] { + background: rgb(127 127 127 / 0.14); } -.history-entry.selected { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); +.version-picker-option[data-disabled] { + cursor: not-allowed; + opacity: 0.42; } -.history-section { - align-items: center; - color: var(--text); - display: flex; - font: 700 11px/1.25 var(--font-sans); - margin-top: 8px; - min-height: 20px; +.version-picker-option code { 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; +.version-comparison-status { + align-items: center; + color: var(--muted); + display: flex; + font: 11px/1.4 var(--font-mono); + gap: 6px; + min-height: 24px; } -.history-entry-subject { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.version-comparison-status.error { + color: var(--danger); } -.history-entry-ref { - align-self: center; - color: var(--sidebar-ref); - font: 12px/1.35 var(--font-mono); - justify-self: start; - min-width: 0; - white-space: nowrap; +.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; } -.history-entry.with-metadata .history-entry-ref { - grid-row: 1 / span 2; +@keyframes version-comparison-spin { + to { + transform: rotate(360deg); + } } -.history-entry-subject { - align-self: center; - color: var(--text); - font: 12px/1.35 var(--font-sans); - min-width: 0; +.version-picker-head { + color: var(--accent, var(--sidebar-text)); + font-weight: 700; } - -.history-entry-meta { - align-items: center; - color: var(--text-secondary, color-mix(in srgb, var(--text) 40%, transparent)); - display: flex; - font: 10px/1.2 var(--font-sans); - gap: 8px; - justify-content: space-between; - white-space: nowrap; +.version-picker-additions { + color: var(--diff-addition); } - -.history-entry-meta > span:first-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; +.version-picker-deletions { + color: var(--diff-deletion); } - -.history-entry-meta > span:last-child { - flex: none; +.version-picker-timing { + color: var(--muted); } -.history-entry-author { - align-items: center; - display: inline-flex; - gap: 5px; +.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; } -.history-entry-author > span:last-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; +.version-base-movement a { + color: inherit; + font-family: var(--font-mono); } - -.history-loading { - color: var(--text); - font: 12px/1.35 var(--font-sans); - padding: 10px 8px; +.version-base-movement-stat, +.version-base-movement small, +.version-commit-evolution small, +.version-walkthrough-structure small { + color: var(--muted); } - -.sidebar-walkthrough-status-shell { - align-items: flex-start; - display: flex; - flex: 1; +.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: max-content max-content 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-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-pill.absorbed-into-base, +.version-commit-kind-pill.ambiguous, +.version-commit-kind-pill.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 { + background: rgb(127 127 127 / 0.12); + color: var(--sidebar-text); +} + +.merge-request-nav-icon { + border-radius: 5px; + display: block; + height: 20px; + width: 20px; +} + +.merge-request-walkthrough-status { + color: var(--muted); + font: 13px/1.4 var(--font-sans); + 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; + gap: 8px; + 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); + display: flex; + flex: none; + height: 38px; + min-height: 38px; + padding: 4px 8px; +} + +.sidebar-settings-line-count { + font: 600 11px/1 var(--font-mono); + margin-left: auto; +} + +.sidebar-search { + -webkit-app-region: no-drag; + background: rgb(127 127 127 / 0.1); + border: 0; + border-radius: 15px; + color: var(--sidebar-text); + corner-shape: squircle; + font: 13px/1.35 var(--font-sans); + height: 30px; + outline: none; + padding: 0 11px; + width: 100%; +} + +.sidebar-search:focus { + box-shadow: 0 0 0 2px rgb(61 135 245 / 0.32); +} + +.sidebar-search::placeholder { + color: var(--muted); +} + +.sidebar-total-row { + align-items: center; + border-top: 1px solid var(--sidebar-border); + color: var(--muted); + display: flex; + flex: none; + font: 600 11px/1 var(--font-sans); + gap: 10px; + justify-content: space-between; + min-height: 38px; + padding: 8px; +} + +.sidebar-total-summary { + align-items: center; + display: inline-flex; + gap: 8px; + min-width: 0; +} + +.sidebar-total-line-count { + display: inline-flex; + flex: none; + font-family: var(--font-mono); + gap: 8px; + white-space: nowrap; +} + +.sidebar-commit-button { + flex: none; + color: var(--sidebar-text); +} + +.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; +} + +.ai-review-drawer { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; +} + +.sidebar-comments-preferences { + border-bottom: 1px solid var(--sidebar-border); + padding: 6px 10px; +} + +.ai-review-drawer-toggle { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +.ai-review-drawer-toggle > span:first-child { + display: grid; + gap: 2px; + min-width: 0; +} + +.ai-review-drawer-toggle small { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-entry-ref { + align-self: center; + color: var(--sidebar-ref); + font: 12px/1.35 var(--font-mono); + justify-self: start; + min-width: 0; + white-space: nowrap; +} + +.history-entry.with-metadata .history-entry-ref { + grid-row: 1 / span 2; +} + +.history-entry-subject { + align-self: center; + color: var(--text); + font: 12px/1.35 var(--font-sans); + min-width: 0; +} + +.history-entry-meta { + align-items: center; + color: var(--text-secondary, color-mix(in srgb, var(--text) 40%, transparent)); + display: flex; + font: 10px/1.2 var(--font-sans); + gap: 8px; + justify-content: space-between; + white-space: nowrap; +} + +.history-entry-meta > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-entry-meta > span:last-child { + flex: none; +} + +.history-entry-author { + align-items: center; + display: inline-flex; + gap: 5px; +} + +.history-entry-author > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-loading { + color: var(--text); + font: 12px/1.35 var(--font-sans); + padding: 10px 8px; +} + +.sidebar-walkthrough-status-shell { + align-items: flex-start; + display: flex; + flex: 1; min-height: 0; } -.sidebar-walkthrough-status-shell .sidebar-walkthrough-status { - border-bottom: 0; - width: 100%; +.sidebar-walkthrough-status-shell .sidebar-walkthrough-status { + border-bottom: 0; + 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 { @@ -1517,16 +2415,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; @@ -1540,6 +2501,43 @@ html[data-codiff-platform='darwin'] .sidebar { overflow: visible; } +.walkthrough-generating-main { + 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 { + color: var(--muted); + font: 12px/1.4 var(--font-sans); +} + .sidebar-comment-list.history-list { padding-top: 6px; } @@ -1571,12 +2569,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 +2702,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 +2738,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 +2771,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 +3059,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 +3763,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 +3807,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 +3860,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 +3929,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 +3966,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 +4062,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 +4092,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 +4155,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 +4769,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 +4829,7 @@ diffs-container .review-comment-thread { .review-submit-button.close { color: var(--text); + white-space: nowrap; } .review-submit-popover { @@ -3787,7 +4938,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 +5053,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 +5328,91 @@ diffs-container .review-comment-thread { display: none; } } + +@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); + 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/App.tsx b/core/App.tsx index e44197cd..f799cbcf 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,24 @@ export default function App() { ); } + if ( + shouldUseLocalMergeRequestHost(state.source) && + !launchOptions.walkthrough && + !launchOptions.walkthroughFile + ) { + 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 +1673,7 @@ export default function App() { focusCommentRequest, gitIdentity, hunkNavigation, + isPullRequest, itemVersionByKey, keymap: codiffConfig.keymap, loadingSectionIds, @@ -1684,7 +1707,6 @@ export default function App() { } /> ) : undefined, - supportsReviewCommentActions: isPullRequest, theme: preferences.theme, viewed, wordWrap, @@ -1853,9 +1875,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 +1892,9 @@ export default function App() { viewed={viewed} walkthroughError={walkthroughError} walkthroughLoading={walkthroughLoading} + walkthroughOutdatedPaths={reloadDeltaPaths} walkthroughProgress={walkthroughProgress} + walkthroughUnread={walkthroughUnread} />
diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index d2a0a018..924c9250 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, versionCommitKindLabel } 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,106 @@ 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 { + combineWalkthroughCommitFiles, + getWalkthroughCommitDiffShas, +} from './lib/narrative-walkthrough.ts'; +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, + WalkthroughGenerationProgress, 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 +155,140 @@ 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' + > + >; + /** Shown in external-link tooltips (for example GitLab or GitHub). */ + providerLabel?: string; + 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; + /** 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); + +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 +310,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 +1207,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 +1384,397 @@ 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; + walkthroughProgress?: WalkthroughGenerationProgress | null; + 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; + /** 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) => { + 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, + versionHistoryLabel = 'Versions', + versionHistoryLoading = false, + versionHistoryWarning = null, + versions = [], + versionWalkthroughStructure: versionWalkthroughStructureProp, + wholeDiffLabel = 'Whole MR', }: ReviewSurfaceProps) { const canComment = commenting?.canComment ?? Boolean(interactive); const deleteShare = useCallback(async () => { @@ -207,11 +1791,156 @@ 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; 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(() => { + queueMicrotask(() => { + setSelectedVersionUnitIds(new Set()); + setVersionUnitFiles({}); + setVersionUnitLoadingIds(new Set()); + setVersionUnitErrors({}); + }); + versionUnitScopeRef.current += 1; + }, [versionCompare?.from.id, versionCompare?.to.id]); + + useEffect(() => { + if (!versionCommitEvolution) { + return; + } + if (versionWalkthroughStructureProp == null) { + queueMicrotask(() => { + 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 +1949,86 @@ 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); + 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( + () => + 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, + versionWalkthroughStructure, + walkthroughCommitShas, + ], + ); 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 +2041,48 @@ 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; + queueMicrotask(() => 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 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> @@ -282,37 +2099,81 @@ 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).flatMap( - (thread) => thread.comments, + (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).filter( + (thread) => showResolvedComments || thread.isResolved !== true, ), - [snapshot.repository.generalComments], + [showResolvedComments, snapshot.repository.generalComments], + ); + const overviewComments = useMemo( + () => + generalCommentThreads.flatMap((thread) => + thread.comments.map((comment) => ({ + canResolve: thread.canResolve === true, + comment, + isResolved: thread.isResolved === true, + })), + ), + [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(() => { + queueMicrotask(() => { + 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 +2183,91 @@ 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; + 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 +2275,153 @@ export function ReviewSurface({ ), [snapshot.preferences.showWhitespace, visibleFiles], ); - const showTotalLineCount = sidebarMode !== 'comments' && totalLineCount.countable; + const showTotalLineCount = + sidebarMode !== 'comments' && sidebarMode !== 'commits' && totalLineCount.countable; + + useEffect(() => { + if (selectedCommitSha) { + queueMicrotask(() => { + setActiveCommitSha(selectedCommitSha); + setSelectedTreeCommitShas(new Set([selectedCommitSha])); + }); + } + }, [selectedCommitSha]); + + const commitDiffTargetSha = + 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 || + !interactive?.onLoadCommitDiff + ) { + return; + } + if (commitFilesBySha[commitDiffTargetSha]) { + return; + } + const requestId = commitLoadRequestRef.current + 1; + commitLoadRequestRef.current = requestId; + queueMicrotask(() => { + 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, + legacyWalkthroughNeedsCommitDiffs, + missingWalkthroughCommitShas, + 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; + queueMicrotask(() => { + 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 +2442,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: ReviewMode) => { + (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 id = crypto.randomUUID(); + setFocusCommentId(id); + setFocusCommentRequest((current) => current + 1); + setLocalReviewComments((current) => [...current, { ...comment, body: '', id }]); + bumpItemVersion(comment.filePath); + }, + [bumpItemVersion, canComment], + ); const activateGeneralComment = useCallback( (commentId: string) => { changeSidebarMode('comments'); @@ -390,6 +2547,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) { @@ -411,7 +2576,32 @@ export function ReviewSurface({ activateGeneralComment(nextComment.id); } }, - [activateGeneralComment, focusedGeneralCommentId, generalComments], + [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) => { @@ -425,24 +2615,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 +2658,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 +2683,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 +2706,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 +2727,6 @@ export function ReviewSurface({ interactive, pullRequestReviewSubmitting, snapshot.repository.source, - activeReviewCommentDraftRef, - reviewCommentsRef, updateActiveReviewCommentDraft, ], ); @@ -605,44 +2780,120 @@ 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; } 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); + 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(() => { - 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 (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; + } - walkthroughRequestPendingRef.current = true; - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); - }, [interactive]); + beginWalkthroughGeneration(options ?? null); + }, + [beginWalkthroughGeneration, interactive], + ); useEffect(() => { - if (sidebarMode === 'walkthrough' && interactive?.walkthroughStatus === 'idle') { - startWalkthroughGeneration(); + 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; + queueMicrotask(() => startWalkthroughGeneration(versionCompareWalkthroughOptions)); + return; + } + if (interactive?.walkthroughStatus === 'idle') { + queueMicrotask(() => startWalkthroughGeneration()); } - }, [interactive?.walkthroughStatus, sidebarMode, startWalkthroughGeneration]); + }, [ + interactive?.walkthroughStatus, + sidebarMode, + startWalkthroughGeneration, + versionCompareActive, + versionCompareWalkthroughOptions, + walkthroughRequestPending, + ]); + useEffect(() => { if (sidebarMode !== 'comments' || generalComments.length === 0) { return; @@ -729,30 +2980,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); }, - [setSelectedPath], + [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); + }, + [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( @@ -762,6 +3109,7 @@ export function ReviewSurface({ activeSearchMatch: null, agentId: snapshot.walkthrough.agent, agentLabel: getAgentLabel(snapshot.walkthrough.agent), + canSubmitReviewComments, codeQualityFindings: snapshot.codeQualityFindings, collapsed, comments: reviewComments, @@ -775,6 +3123,7 @@ export function ReviewSurface({ gitIdentity, hunkNavigation: null, initialMarkdownPreviewSectionIds, + isPullRequest: snapshot.repository.source.type === 'pull-request', isReadOnly: !canComment, itemVersionByKey, keymap, @@ -796,7 +3145,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 +3208,23 @@ export function ReviewSurface({ onActiveBlockChange: (blockId: string) => void, ) => { return ( - +
+ +
); }; @@ -885,6 +3240,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 +3286,88 @@ export function ReviewSurface({ previousWalkthroughStatusRef.current = walkthroughStatus; }, [walkthroughStatus]); const walkthroughReady = !interactive || walkthroughStatus === 'ready'; + const versionWalkthroughFilesMissing = + 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' + : baselineWalkthroughStructure === '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 walkthroughGenerationProgress = interactive?.walkthroughProgress ?? null; const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' - : 'Generating walkthrough…'; + : computingVersionChanges + ? 'Computing version changes…' + : walkthroughIdle && !walkthroughBusy + ? 'Walkthrough not generated' + : loadingWalkthroughLookup + ? 'Loading walkthrough…' + : 'Generating 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.' + : (walkthroughGenerationProgress?.summary ?? + interactive?.walkthroughError ?? + (loadingWalkthroughLookup + ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough.` + : null)); + const walkthroughProgressLabel = computingVersionChanges + ? 'Computing version changes…' + : loadingWalkthroughLookup + ? 'Loading walkthrough…' + : generatingWalkthrough + ? 'Generating 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 = + baselineWalkthroughStructure === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; + const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); const reviewModes = [ { icon: , @@ -1056,43 +3511,682 @@ export function ReviewSurface({ value={fileSearchQuery} />
+ {interactive && sidebarMode !== 'comments' ? ( +
+
+ {versionCompareActive ? ( + + ) : ( + + Diff scope + + )} +
+ + +
+
+ {!versionCompareActive && versionHistoryWarning ? ( +
{versionHistoryWarning}
+ ) : null} + {versionSectionExpanded && versionCompareActive ? ( +
+ {versionHistoryLoading ? ( +
+ + {`Loading ${versionHistoryLabel.toLowerCase()}…`} +
+ ) : 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 ? 'Removed' : 'Added'} + + + {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 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' + : 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) => ( +
+ + Rebase 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' + : `${wholeDiffLabel} (${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 ? ( +
+ + {baselineWalkthroughStructure === 'commit-by-commit' + ? 'Structured by commits' + : `${wholeDiffLabel} (${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: + baselineWalkthroughStructure === 'commit-by-commit' + ? 'commit-by-commit' + : 'whole-diff', + }) + : () => requestWalkthrough({ force: true }) + } + showWhitespace={snapshot.preferences.showWhitespace} + walkthrough={sharedWalkthrough} + /> + ) : (
-
- {walkthroughFailed ? ( - {walkthroughStatusTitle} - ) : ( - - )} - {walkthroughStatusDescription ? {walkthroughStatusDescription} : null} +
+ {walkthroughBusy && !versionCompareActive && interactive?.reviewStrategy ? ( +
+ + Generating ·{' '} + {baselineWalkthroughStructure === 'commit-by-commit' + ? 'Commit-by-commit' + : wholeDiffLabel} + + + {queuedReviewStructure ? ( + + Queued:{' '} + {queuedReviewStructure === 'commit-by-commit' + ? 'commit-by-commit' + : wholeDiffLabel} + + ) : null} +
+ ) : null} +
+ {walkthroughFailed || + (walkthroughIdle && !walkthroughBusy && !computingVersionChanges) ? ( + <> + {walkthroughStatusTitle} + {walkthroughStatusDescription ? ( + {walkthroughStatusDescription} + ) : null} + + ) : ( + + )} +
)} @@ -1110,8 +4204,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 +4355,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 +4369,328 @@ export function ReviewSurface({ walkthroughNotes={emptyWalkthroughNotes} /> ) + ) : versionWalkthroughFilesMissing ? ( +
+
+ Commit walkthrough code is unavailable +

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

+ {versionCompareWalkthroughOptions ? ( + + ) : null} +
+
+ ) : legacyWalkthroughDiffLoading ? ( +
Loading commit walkthrough code…
+ ) : legacyWalkthroughDiffError ? ( +
+
+ Unable to load commit walkthrough code +

{legacyWalkthroughDiffError}

+
+
) : 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, + providerLabel = 'provider', + reviewStrategy, + selectedCommitSha, + settingsBar, + sourceDescriptionFooterAside, + state, + title, + versionCommitEvolution, + versionCommitEvolutionError, + versionCommitEvolutionLoading, + versionCompare, + versionCompareEnabled, + versionCompareError, + versionCompareFromId, + versionCompareLoading, + versionCompareToId, + versionHistoryLabel = 'Versions', + versionHistoryLoading, + versionHistoryWarning, + versions, + versionWalkthroughStructure, + walkthrough, + walkthroughError, + walkthroughProgress, + walkthroughStatus, + wholeDiffLabel = 'Whole MR', +}: 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-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__/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/__tests__/LocalMergeRequestReviewHost.test.tsx b/core/__tests__/LocalMergeRequestReviewHost.test.tsx new file mode 100644 index 00000000..335a5127 --- /dev/null +++ b/core/__tests__/LocalMergeRequestReviewHost.test.tsx @@ -0,0 +1,768 @@ +/** + * @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 () => {}), + generateReviewWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + 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', + })), + 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, + })), + 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), + 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, + 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 shared orchestration 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 generateReviewWalkthrough = vi.fn(async () => ({ + status: 'ready' as const, + walkthrough, + })); + + window.codiff = createCodiffMock({ generateReviewWalkthrough }); + + const onHome = vi.fn(); + const app = await renderReact( + , + ); + + try { + await waitFor(() => { + expect(generateReviewWalkthrough).toHaveBeenCalled(); + }); + 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(); + } +}); + +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 ceee97b0..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 { @@ -786,7 +786,7 @@ test('read-only review comments render safe details blocks', async () => { } satisfies ReviewComment; const view = await renderReact( - , + , ); try { @@ -1360,11 +1360,12 @@ test('failed pull request comments keep their draft and can be retried', async ( const onUpdateComment = vi.fn(); const view = await renderReact( , ); @@ -1408,10 +1409,10 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy const onSubmitComment = vi.fn(); const view = await renderReact( , ); @@ -1460,9 +1461,9 @@ test('file comments can be created for GitLab merge requests but not GitHub pull 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({ @@ -1483,9 +1484,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 +1511,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 />, ); @@ -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__/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..96f89a6b 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,76 @@ 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 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 () => { 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/__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__/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/__tests__/walkthrough-authoring.test.ts b/core/__tests__/walkthrough-authoring.test.ts index dd17e493..e745bef6 100644 --- a/core/__tests__/walkthrough-authoring.test.ts +++ b/core/__tests__/walkthrough-authoring.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vite-plus/test'; import { attachVersionCommentReferences, + buildVersionCommitOverviewPrompt, buildWalkthroughPrompt, buildWalkthroughPromptInput, composeUnitWalkthroughs, @@ -214,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', @@ -281,6 +282,28 @@ test('includes version-commit guidance and composes unit walkthroughs', () => { expect(composed.title).toContain('v1'); }); +test('scopes version-comparison Review focus to changes since the earlier version', () => { + const overviewPrompt = buildVersionCommitOverviewPrompt({ + entries: [ + { + context: { + after: { sha: 'b'.repeat(40), 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/LocalMergeRequestReviewHost.tsx b/core/app/LocalMergeRequestReviewHost.tsx new file mode 100644 index 00000000..0c332de3 --- /dev/null +++ b/core/app/LocalMergeRequestReviewHost.tsx @@ -0,0 +1,871 @@ +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, + PullRequestExistingReviewComment, + PullRequestReviewComment, + PullRequestReviewEvent, + RepositoryState, + WalkthroughGenerationProgress, + ReviewEvolutionUnit, + ReviewStrategySummary, + ReviewSource, +} from '../types.ts'; +import { Button } from './components/Button.tsx'; + +const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ + ...settings, +}); + +const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); + +const toCommitListEntries = ( + entries: ReadonlyArray, +): ReadonlyArray => + 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') { + 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 [reviewStrategy, setReviewStrategy] = useState(null); + const [walkthrough, setWalkthrough] = useState(null); + 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; + }, [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( + () => + window.codiff.onWalkthroughProgress((event) => { + if (event.generation) { + setWalkthroughProgress(event.generation); + } + }), + [], + ); + + 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; + } + applyHistory(history); + }; + + loadCommits().catch(() => { + if (!canceled) { + setCommits([]); + } + }); + + 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; + }; + }, [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); + 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( + 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 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; + 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; + } + + const requestId = ++walkthroughRequestRef.current; + setWalkthroughError(null); + try { + 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) { + return; + } + 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( + () => ({ + 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 ( + void refreshRemoteReview()} + size="sm" + title={ + refreshError + ? `Refresh failed: ${refreshError}` + : `Last refreshed ${new Date(lastRefreshAt).toLocaleString()}` + } + type="button" + variant="ghost" + > + + {`Refresh ${state.source.provider === 'gitlab' ? 'MR' : 'PR'} · updated ${shortUpdatedAge(lastRefreshAt, refreshNow ?? lastRefreshAt)}`} + + } + state={state} + title={title} + 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} + /> + ); +} + +/** + * Desktop host adapter for pull-request / merge-request sources. + * Feeds Core `MergeRequestReviewApp` with local IPC-backed data and actions. + * GitLab sources also load package-backed version history / compare / evolution. + */ +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/core/app/components/CommitRefTooltip.tsx b/core/app/components/CommitRefTooltip.tsx new file mode 100644 index 00000000..1dcf7827 --- /dev/null +++ b/core/app/components/CommitRefTooltip.tsx @@ -0,0 +1,106 @@ +import { Tooltip } from '@base-ui/react/tooltip'; +import { ExternalLink } from 'lucide-react'; +import type { VersionCommitKind } from '../../types.ts'; + +export type CommitRefSummary = { + additions?: number; + authoredAt?: number | string | null; + authorName?: string; + deletions?: number; + 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, + 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.versionCommitKind ? ( + + {versionCommitKindLabel(commit.versionCommitKind)} + + ) : null} + {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..835c3e8b --- /dev/null +++ b/core/app/components/CommitScopePanel.tsx @@ -0,0 +1,169 @@ +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'; + +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'; + 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; + } + const versionCommitKind = + unit.kind !== 'commit' && 'after' in unit && unit.after?.sha === commit.sha + ? unit.kind + : undefined; + 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..466f25ab 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 @@ -423,13 +720,24 @@ function HistorySidebar({ type="button" > - {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.ref - : 'local'} + {row.source.type === 'commit' ? ( + + ) : row.source.type === 'pull-request' || row.source.type === 'branch-diff' ? ( + row.ref + ) : ( + 'local' + )} {row.subject} {hasMetadata ? ( diff --git a/core/app/components/walkthrough/NarrativeSidebar.tsx b/core/app/components/walkthrough/NarrativeSidebar.tsx index 8630857f..c7d51ced 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, versionCommitKindLabel } 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,107 @@ 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 toVersionCommitKind = (kind: string | undefined) => { + switch (kind) { + case 'introduced': + case 'removed': + case 'revised': + case 'retained': + case 'rewritten-same-patch': + case 'absorbed-into-base': + case 'ambiguous': + return kind; + default: + return undefined; + } +}; + 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 +275,84 @@ export function NarrativeSidebar({ return (
- Review focus +
+ Review focus + {onRegenerateWalkthrough ? ( + + ) : null} +

{renderInlineMarkdown(walkthrough.focus)}

{walkthroughView.chapters.map((chapter) => ( -
+
+ {chapter.commit + ? (() => { + const versionCommitKind = + chapter.commit.versionCommitKind ?? + toVersionCommitKind(commitEvolutionKinds?.get(chapter.commit.sha)); + return ( +
`${driver.shortSha} ${driver.subject}`, + ), + chapter.commit.revisionCause, + ] + .filter(Boolean) + .join(' · ')} + > + {versionCommitKind ? ( + + {versionCommitKindLabel(versionCommitKind)} + + ) : 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 +373,14 @@ export function NarrativeSidebar({
))} + ); } + +export { agentLabel }; diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index ac072a37..b774817a 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,31 @@ 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 +198,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 +291,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 +353,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 +377,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 +400,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 +549,17 @@ function Arc({
+ {chapter.commit ? ( + + ) : null} {chapter.title}
@@ -497,26 +662,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 +705,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; @@ -683,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' ? 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/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index a116272b..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,12 +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; }) { @@ -40,20 +61,52 @@ 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…'); + 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} - - {showTimer ? `${elapsedSeconds}s` : '0s'} + + + + {label} + {counts ? ` · ${counts}` : ''} + {summary ? ` · ${summary}` : ''} + + + + {units.length > 0 ? ( +
    + {units.map((unit) => ( +
  • + + {unitStatusLabel(unit.status)} + + {unit.label} +
  • + ))} +
+ ) : null}
); 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/app/hooks/useAppWalkthrough.ts b/core/app/hooks/useAppWalkthrough.ts index 41cb5ffe..a088f412 100644 --- a/core/app/hooks/useAppWalkthrough.ts +++ b/core/app/hooks/useAppWalkthrough.ts @@ -51,7 +51,7 @@ export function useAppWalkthrough({ ); const [walkthroughLoading, setWalkthroughLoading] = useState(false); const [walkthroughProgress, setWalkthroughProgress] = useState<{ - phase: WalkthroughProgressEvent['phase'] | null; + phase: NonNullable | null; responseLabelIndex: number; stageRevision: number; }>({ phase: null, responseLabelIndex: -1, stageRevision: 0 }); @@ -93,11 +93,15 @@ export function useAppWalkthrough({ useEffect( () => window.codiff.onWalkthroughProgress((progress) => { + const phase = progress.phase; + if (!phase) { + return; + } setWalkthroughProgress((current) => - current.phase === progress.phase + current.phase === phase ? current : { - phase: progress.phase, + phase, responseLabelIndex: current.responseLabelIndex, stageRevision: current.stageRevision + 1, }, diff --git a/core/global.d.ts b/core/global.d.ts index 664d50a8..05f41c4d 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -1,6 +1,7 @@ import type { CodiffConfig } from './config/types.ts'; import type { AgentSkillStatus, + ChangedFile, CodiffFeatureFlags, CodiffLaunchOptions, CodiffMarkdownDocument, @@ -9,7 +10,20 @@ import type { DiffImageContentResult, DiffSection, DiffSectionContentRequest, + GenerateLocalReviewWalkthroughRequest, + GenerateLocalReviewWalkthroughResult, GitIdentity, + GitLabReviewVersionCompareRequest, + GitLabReviewVersionCompareResult, + GitLabReviewVersionsRequest, + GitLabReviewVersionUnitDiffRequest, + ReviewVersionCompareRequest, + ReviewVersionCompareResult, + ReviewVersionsRequest, + ReviewVersionsResult, + ReviewVersionUnitDiffRequest, + StoredLocalReviewWalkthroughRequest, + StoredLocalReviewWalkthroughResult, NarrativeWalkthroughRequestOptions, NarrativeWalkthroughResult, PlanHandoffStatus, @@ -19,6 +33,7 @@ import type { ReviewAssistantRequest, ReviewAssistantResult, ReviewSource, + ReviewVersionOption, SaveMarkdownDocumentRequest, SaveMarkdownDocumentResult, SharePlanResult, @@ -46,12 +61,24 @@ declare global { request: WalkthroughCommitRequest, ) => Promise; decreaseCodeFontSize: () => Promise; + generateReviewWalkthrough: ( + request: GenerateLocalReviewWalkthroughRequest, + ) => Promise; getAgentSkillStatus: () => Promise; getConfig: () => Promise; getDiffImageContent: (request: DiffImageContentRequest) => Promise; getDiffSectionContent: (request: DiffSectionContentRequest) => Promise; getFeatureFlags: () => Promise; getGitIdentity: () => Promise; + getGitLabReviewVersionCompare: ( + request: GitLabReviewVersionCompareRequest, + ) => Promise; + getGitLabReviewVersions: ( + request: GitLabReviewVersionsRequest, + ) => Promise>; + getGitLabReviewVersionUnitDiff: ( + request: GitLabReviewVersionUnitDiffRequest, + ) => Promise>; getLaunchOptions: () => Promise; getMarkdownDocument: (request: { kind: CodiffMarkdownDocument['kind']; @@ -65,6 +92,16 @@ declare global { getPreferences: () => Promise; getRepositoryHistory: (limit?: number, source?: ReviewSource) => Promise; getRepositoryState: (source?: ReviewSource) => Promise; + getReviewVersionCompare: ( + request: ReviewVersionCompareRequest, + ) => Promise; + getReviewVersions: (request: ReviewVersionsRequest) => Promise; + getReviewVersionUnitDiff: ( + request: ReviewVersionUnitDiffRequest, + ) => Promise>; + getStoredReviewWalkthrough: ( + request: StoredLocalReviewWalkthroughRequest, + ) => Promise; getTerminalHelperStatus: () => Promise; increaseCodeFontSize: () => Promise; installAgentSkill: () => Promise; diff --git a/core/index.ts b/core/index.ts index f3066731..6fa09aa3 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,7 +105,45 @@ export type { SharedPlanSnapshot, SharedWalkthroughSnapshot, WalkthroughCommentReference, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, WalkthroughGenerationInput, WalkthroughShareManifestV1, WalkthroughHunk, } from './types.ts'; +export { + classifyGitLabCommit, + classifyMergeRequestReviewStrategy, + classifyReviewCommit, + classifyReviewStrategy, + orderCommitsTopologically, + overrideMergeRequestReviewStrategy, + reviewStructureFromStrategy, + versionCompareReviewStructureKey, + type ClassifiedCommit, + type ClassifiedCommitRole, + type GitLabMergeRequestCommitLike, + type MergeRequestReviewStrategy, + type ReviewStrategyCommitLike, +} from './lib/review-strategy.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/generate-review-walkthrough.ts b/core/lib/generate-review-walkthrough.ts index 1cbc47d3..6c67ca97 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 = @@ -97,6 +110,17 @@ const toPromptOptionsForUnit = ( range: { fromLabel: string; toLabel: string }, base: WalkthroughPromptOptions | undefined, ): WalkthroughPromptOptions => { + if (unit.kind === 'commit') { + return { + ...base, + commitContext: { + sha: unit.commit.sha, + subject: unit.commit.subject, + }, + versionCommitContext: undefined, + versionCompareRange: undefined, + }; + } const after = unitAfter(unit); const before = unitBefore(unit); return { @@ -127,6 +151,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 +262,139 @@ 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); + if (unit.kind === 'commit') { + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + reportUnitProgress(`Finished ${unitLabel(unit)}.`); + return { + entry: { + context: { + commit: { + sha: unit.commit.sha, + shortSha: unit.commit.shortSha, + subject: unit.commit.subject, + ...(unit.commit.webUrl ? { webUrl: unit.commit.webUrl } : {}), + }, + kind: 'mr-commit', + }, + state: unitState, + walkthrough, + }, + }; + } + 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 +408,24 @@ export async function generateReviewWalkthrough( }; } + let focus: string | undefined; + reportUnitProgress('Composing commit walkthroughs.', 'combining'); + if (input.runOverviewModel && entries.some((entry) => entry.context.kind === 'version-commit')) { + 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, }); diff --git a/core/lib/narrative-walkthrough.ts b/core/lib/narrative-walkthrough.ts index 000c29e1..9234860b 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,21 +338,42 @@ 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; } + for (const file of candidates) { + const section = file.sections.find((candidate) => candidate.id === sectionId); + if (section) { + return { file, section }; + } + } + return null; +}; - return { file, section }; +export const getWalkthroughCommitDiffShas = ( + walkthrough: Pick, +): ReadonlyArray => { + const seen = new Set(); + return walkthrough.chapters.flatMap((chapter) => { + const sha = chapter.commit?.gitSha ?? chapter.commit?.sha; + if (!sha || seen.has(sha)) { + return []; + } + seen.add(sha); + return [sha]; + }); }; +export const combineWalkthroughCommitFiles = ( + commitShas: ReadonlyArray, + filesBySha: Readonly>>, +): ReadonlyArray => commitShas.flatMap((sha) => filesBySha[sha] ?? []); + const walkthroughHunkRunKey = (item: WalkthroughHunkGroup, hunks: ReadonlyArray) => `${item.id}:${hunks.map((hunk) => hunk.id).join(',')}`; 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/lib/review-strategy.ts b/core/lib/review-strategy.ts new file mode 100644 index 00000000..0a74f862 --- /dev/null +++ b/core/lib/review-strategy.ts @@ -0,0 +1,338 @@ +// Provider-neutral review-structure classifier for pull and merge requests. + +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; +}; + +export type ReviewStrategyCommitLike = GitLabMergeRequestCommitLike; + +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 } : {}), + }; +}; + +export const classifyReviewCommit = classifyGitLabCommit; + +/** + * 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 classifyReviewStrategy = classifyMergeRequestReviewStrategy; + +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/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/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/types.ts b/core/types.ts index 161ccd1c..3df048d6 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; @@ -1107,7 +1129,6 @@ export type PullRequestReviewComment = { body: string; filePath: string; lineNumber?: number; - sectionId?: string; side?: 'additions' | 'deletions'; startLineNumber?: number; startSide?: 'additions' | 'deletions'; @@ -1199,3 +1220,87 @@ export type SubmitPullRequestReviewRequest = { event: PullRequestReviewEvent; source: Extract; }; + +export type GitLabReviewVersionsRequest = { + source: Extract; +}; + +export type GitLabReviewVersionCompareRequest = { + from?: ReviewVersionCompareEndpoint; + fromId?: string; + source: Extract; + to?: ReviewVersionCompareEndpoint; + toId?: string; +}; + +export type ReviewVersionCompareEndpoint = + | { id: string; kind: 'version' } + | { kind: 'base' } + | { kind: 'head-sha'; sha: string } + | { + baseSha: string; + commentId: string; + headSha: string; + kind: 'comment-position'; + startSha: string; + }; + +export type GitLabReviewVersionCompareResult = { + versionCommitEvolution: ReviewCommitEvolution | null; + versionCommitEvolutionError: string | null; + versionCompare: DiffComparisonView; + warning?: string | null; +}; + +export type GitLabReviewVersionUnitDiffRequest = { + source: Extract; + unit: ReviewEvolutionUnit; +}; + +/** Provider-neutral aliases used by the local review host. */ +export type ReviewVersionsRequest = GitLabReviewVersionsRequest; +export type ReviewVersionCompareRequest = GitLabReviewVersionCompareRequest; +export type ReviewVersionCompareResult = GitLabReviewVersionCompareResult & { + warning?: string | null; +}; +export type ReviewVersionUnitDiffRequest = GitLabReviewVersionUnitDiffRequest; +export type ReviewVersionsResult = { + versions: ReadonlyArray; + warning?: string | null; +}; + +export type GenerateLocalReviewWalkthroughRequest = { + force?: boolean; + source: Extract; + structure?: 'auto' | 'commit-by-commit' | 'whole-diff' | 'units'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + }; +}; + +export type StoredLocalReviewWalkthroughRequest = Omit< + GenerateLocalReviewWalkthroughRequest, + 'force' | 'unitId' +>; + +export type StoredLocalReviewWalkthroughResult = + | { + status: 'ready'; + walkthrough: NarrativeWalkthrough; + } + | { + status: 'missing'; + }; + +export type GenerateLocalReviewWalkthroughResult = + | { + status: 'ready'; + walkthrough: NarrativeWalkthrough; + } + | { + code?: string; + reason: string; + status: 'failed' | 'unavailable'; + }; 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 5142685f..273530a7 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,37 @@ 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 { + flex-shrink: 0; +} .wt-toc-chapter-head { align-items: center; display: grid; @@ -329,15 +384,73 @@ 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; + 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); @@ -453,6 +566,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 +682,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 +1436,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 +1446,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 +1455,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 +1468,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; +} diff --git a/docs/plan-local-parity.md b/docs/plan-local-parity.md new file mode 100644 index 00000000..daa2b38b --- /dev/null +++ b/docs/plan-local-parity.md @@ -0,0 +1,528 @@ +# 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 +Landed as `@nkzw/codiff-github` (transport + pure history). Electron keeps `gh`/`git` hosts via `electron/git-state/github-history/` and `electron/github-history-bridge.cjs`. Originally planned as temporary landing 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. This step is the contract that keeps Local +and Web from forking walkthrough semantics. + +#### Why this exists + +Walkthrough generation has three layers: + +1. **Plan** — whole-diff vs units (from evolution recommendation or user override) +2. **Authoring** — prompt build, draft parse/normalize, unit composition + (`walkthrough-authoring`) +3. **Runtime** — model IDs, process/Think transport, retries, cache, fanout + +Hosts must own (3). They must **not** reimplement (1) or (2). The shared helper +is the single entry for (1)+(2) with a host-injected `runModel` for (3). + +#### API + +```ts +generateReviewWalkthrough({ + agent, + plan?, // optional; resolved from evolution+structure when omitted + evolution?, // Core ReviewCommitEvolution for unit plans + structure?, // auto | whole-diff | units | commit-by-commit + states: { + whole?, // required for whole-diff and for composition parent + byUnitId?, // ReviewUnit.id -> RepositoryState + }, + promptOptions?, // versionCompareRange, versionCommitContext, etc. + runModel, // host-provided async ({agent, prompt, state}) => {draft} +}) +``` + +Behavior: + +- **whole-diff:** one `buildWalkthroughPrompt` + `runModel` + `normalizeWalkthroughDraft` +- **units:** for each reviewable unit with a state, independent prompt/run/normalize, + then `composeUnitWalkthroughs` +- missing unit states are skipped and reported; all-fail => `status: 'failed'` + +#### Local wiring + +- IPC `generateReviewWalkthrough` materializes whole/unit `RepositoryState`s + (baseline PR state or version-compare files + per-unit diffs) +- `runModel` shells to the window agent (codex/claude/opencode/pi) with the + narrative response schema +- GitLab and GitHub hosts both call the same IPC → same Core helper + +#### Web adoption (later) + +- Worker/Think implements `runModel` +- DO fanout may prebuild `byUnitId` states and still call this helper +- D1/R2 cache keys stay host-side; helper remains pure orchestration + +Acceptance: + +- local GitLab and GitHub both call the same orchestration helper +- composition and prompt options remain in `walkthrough-authoring` +- no provider-specific prompt forks in Electron + +### 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. diff --git a/electron/__tests__/github-review-history.test.ts b/electron/__tests__/github-review-history.test.ts new file mode 100644 index 00000000..d949e548 --- /dev/null +++ b/electron/__tests__/github-review-history.test.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module'; +import { expect, test } from 'vite-plus/test'; + +test('listGitHubReviewVersions builds head timeline labels without GitLab version numbers', async () => { + const { chmod, mkdtemp, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + + const directory = await mkdtemp(join(tmpdir(), 'codiff-gh-history-')); + const fakeGh = join(directory, 'gh'); + const before = 'a'.repeat(40); + const after = 'b'.repeat(40); + const current = 'c'.repeat(40); + const base = '0'.repeat(40); + await writeFile( + fakeGh, + `#!/usr/bin/env node +const args = process.argv.slice(2); +const pathArg = args.find((a) => a.startsWith('/repos/')) || ''; +process.stdin.resume(); +process.stdin.on('end', () => { + if (pathArg.includes('/timeline')) { + process.stdout.write(JSON.stringify([ + { + event: 'head_ref_force_pushed', + before: '${before}', + after: '${after}', + created_at: '2026-01-02T00:00:00.000Z', + actor: { login: 'ada' }, + }, + ])); + } else if (pathArg.includes('/pulls/')) { + process.stdout.write(JSON.stringify({ + head: { sha: '${current}' }, + base: { sha: '${base}' }, + })); + } else { + process.stdout.write('[]'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGh, 0o755); + const previous = process.env.CODIFF_GH_PATH; + process.env.CODIFF_GH_PATH = fakeGh; + try { + const require = createRequire(import.meta.url); + const { listGitHubReviewVersions } = + require('../git-state/github-history/github-review-history.cjs') as { + listGitHubReviewVersions: ( + repoRoot: string, + source: { + number: number; + owner: string; + provider: 'github'; + repo: string; + type: 'pull-request'; + url: string; + }, + ) => Promise<{ + versions: ReadonlyArray<{ id: string; range: { head: { label: { text: string } } } }>; + warning: string | null; + }>; + }; + const { versions, warning } = await listGitHubReviewVersions(directory, { + number: 12, + owner: 'nkzw-tech', + provider: 'github', + repo: 'codiff', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }); + expect(warning).toBeNull(); + expect(versions.map((v) => v.id)).toEqual([before, after, current]); + const labels = versions.map((v) => v.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); + } finally { + if (previous == null) delete process.env.CODIFF_GH_PATH; + else process.env.CODIFF_GH_PATH = previous; + } +}); 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/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts new file mode 100644 index 00000000..82ef0d6d --- /dev/null +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -0,0 +1,82 @@ +import { chmod, mkdtemp, readFile, 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 { 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('/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 })]); + const calls = (await readFile(callsPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { args: Array }); + expect(calls[0]?.args).toContain('/projects/group%2Fproject/merge_requests/7/versions'); + expect(calls[0]?.args).not.toContain( + '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + ); +}); diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 7d62e2b5..70f3399e 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,16 @@ 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 +909,7 @@ test('normalizes walkthroughs made only of synthetic hunks', () => { status: 'modified', }, ]; - const result = normalizeNarrativeWalkthrough( + const result = await normalizeNarrativeWalkthrough( { chapters: [ { @@ -1000,13 +959,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 +974,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 +999,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 +1019,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 +1043,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 +1052,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 +1062,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 +1076,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 +1084,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 +1098,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 +1115,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 +1123,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 +1134,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 +1148,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 +1166,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 +1179,12 @@ 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 +1221,32 @@ 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 +1254,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 +1268,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 +1283,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/__tests__/pull-request-review.test.ts b/electron/__tests__/pull-request-review.test.ts index 26c6a259..4162b53e 100644 --- a/electron/__tests__/pull-request-review.test.ts +++ b/electron/__tests__/pull-request-review.test.ts @@ -8,21 +8,26 @@ import { expect, test } from 'vite-plus/test'; import { removeGitTestDirectory } from '../../core/__tests__/helpers/git.ts'; const require = createRequire(import.meta.url); -const { submitPullRequestReview } = require('../git-state/pull-request.cjs') as { - submitPullRequestReview: ( - launchPath: string, - request: { - body?: string; - comments: ReadonlyArray>; - event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; - source: { - provider: 'github'; - type: 'pull-request'; - url: string; - }; - }, - ) => Promise; -}; +const { readPullRequestDiff, submitPullRequestReview } = + require('../git-state/pull-request.cjs') as { + readPullRequestDiff: ( + repoRoot: string, + pullRequest: { number: number; owner: string; repo: string; url: string }, + ) => Promise; + submitPullRequestReview: ( + launchPath: string, + request: { + body?: string; + comments: ReadonlyArray>; + event: 'APPROVE' | 'COMMENT' | 'REQUEST_CHANGES'; + source: { + provider: 'github'; + type: 'pull-request'; + url: string; + }; + }, + ) => Promise; + }; const execFileAsync = promisify(execFile); @@ -155,3 +160,39 @@ process.stdin.on('end', () => { await removeGitTestDirectory(directory); } }); + +test('falls back to per-file patches when GitHub rejects an oversized aggregate diff', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-pull-request-diff-')); + const fakeBin = join(directory, 'bin'); + const fakeGh = join(fakeBin, 'gh'); + const previousPath = process.env.PATH; + + try { + await mkdir(fakeBin); + await writeFile( + fakeGh, + `#!/usr/bin/env node +process.stderr.write('gh: Sorry, the diff exceeded the maximum number of lines (20000) (HTTP 406) when trying to use this endpoint.\\n'); +process.exit(1); +`, + ); + await chmod(fakeGh, 0o755); + process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; + + await expect( + readPullRequestDiff(directory, { + number: 12, + owner: 'nkzw-tech', + repo: 'codiff', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + }), + ).resolves.toBe(''); + } finally { + if (previousPath == null) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + await removeGitTestDirectory(directory); + } +}); diff --git a/electron/__tests__/walkthrough-progress.test.ts b/electron/__tests__/walkthrough-progress.test.ts index 5049ed48..b59d4afd 100644 --- a/electron/__tests__/walkthrough-progress.test.ts +++ b/electron/__tests__/walkthrough-progress.test.ts @@ -5,8 +5,8 @@ const require = createRequire(import.meta.url); const { createWalkthroughProgressReporter } = require('../walkthrough-progress.cjs') as { createWalkthroughProgressReporter: (webContents: { isDestroyed: () => boolean; - send: (channel: string, progress: { phase: string }) => void; - }) => (phase: string) => void; + send: (channel: string, progress: unknown) => void; + }) => (update: string | { phase: string; summary: string }) => void; }; test('forwards repeated real progress events while the request is current', () => { @@ -23,18 +23,23 @@ test('forwards repeated real progress events while the request is current', () = reportProgress('response-received'); reportProgress('response-received'); + reportProgress({ phase: 'preparing', summary: 'Preparing 2 commit diffs.' }); expect(send.mock.calls).toEqual([ ['codiff:walkthroughProgress', { phase: 'response-received' }], ['codiff:walkthroughProgress', { phase: 'response-received' }], + [ + 'codiff:walkthroughProgress', + { generation: { phase: 'preparing', summary: 'Preparing 2 commit diffs.' } }, + ], ]); destroyed = true; reportProgress('agent-generation'); - expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(3); destroyed = false; current = false; reportProgress('agent-generation'); - expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(3); }); diff --git a/electron/generate-review-walkthrough-bridge.cjs b/electron/generate-review-walkthrough-bridge.cjs new file mode 100644 index 00000000..500fa625 --- /dev/null +++ b/electron/generate-review-walkthrough-bridge.cjs @@ -0,0 +1,39 @@ +// @ts-check + +/** + * CJS bridge to Core generateReviewWalkthrough (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGenerate = async () => { + if (!modulePromise) { + // generateReviewWalkthrough is exported from walkthrough-authoring barrel after build. + const modulePath = join(__dirname, '../core/dist/walkthrough-authoring.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +/** + * @param {Parameters[0]} input + */ +const generateReviewWalkthrough = async (input) => { + const mod = await loadGenerate(); + if (typeof mod.generateReviewWalkthrough !== 'function') { + // Fallback: dynamic import of core dist index if barrel not yet rebuilt. + const indexPath = join(__dirname, '../core/dist/index.mjs'); + const index = await import(pathToFileURL(indexPath).href); + return index.generateReviewWalkthrough(input); + } + return mod.generateReviewWalkthrough(input); +}; + +module.exports = { + generateReviewWalkthrough, + loadGenerate, +}; diff --git a/electron/git-state/github-history/gh-github-transport.cjs b/electron/git-state/github-history/gh-github-transport.cjs new file mode 100644 index 00000000..944d5201 --- /dev/null +++ b/electron/git-state/github-history/gh-github-transport.cjs @@ -0,0 +1,205 @@ +// @ts-check + +/** + * gh-backed GitHubTransport 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 GH_NOT_FOUND_CODE = 'GH_NOT_FOUND'; +const GH_NOT_FOUND_MESSAGE = + 'GitHub support requires gh. Install gh, authenticate it, and verify `gh --version` works in Terminal. Codiff searches PATH, ~/.local/bin/gh, /opt/homebrew/bin/gh, and /usr/local/bin/gh. If gh is installed somewhere else, launch Codiff with `CODIFF_GH_PATH=/absolute/path/to/gh codiff`.'; + +/** @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 createGhNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GH_NOT_FOUND_MESSAGE} ${detail}` : GH_NOT_FOUND_MESSAGE) + ); + error.code = GH_NOT_FOUND_CODE; + return error; +}; + +const getGhCommand = () => { + const ghPath = process.env.CODIFF_GH_PATH?.trim(); + if (ghPath) { + if (isExecutableFile(ghPath)) { + return ghPath; + } + throw createGhNotFoundError( + `CODIFF_GH_PATH is set to ${JSON.stringify(ghPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('gh'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/gh'), + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGhNotFoundError(); +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @param {unknown} [input] + * @returns {Promise} + */ +const runGhApi = (repoRoot, args, input) => + new Promise((resolve, reject) => { + let command; + try { + command = getGhCommand(); + } catch (error) { + reject(error); + return; + } + + const child = spawn(command, ['api', ...args], { + cwd: repoRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** @type {Array} */ + const stdout = []; + /** @type {Array} */ + 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' + ? createGhNotFoundError() + : error, + ); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject( + new Error( + Buffer.concat(stderr).toString('utf8').trim() || `gh api exited with code ${code}.`, + ), + ); + } + }); + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + }); + +/** + * @param {{ repoRoot: string }} options + */ +const createGhGitHubTransport = ({ repoRoot }) => { + /** + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * body?: unknown, + * paginate?: boolean, + * }} request + */ + const requestText = async (request) => { + /** @type {Array} */ + const args = []; + if (request.paginate) { + args.push('--paginate'); + } + if (request.method && request.method !== 'GET') { + args.push('--method', request.method); + } + let path = request.path.startsWith('/') ? request.path : `/${request.path}`; + if (request.query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(request.query)) { + params.set(key, String(value)); + } + const query = params.toString(); + if (query) { + path = `${path}${path.includes('?') ? '&' : '?'}${query}`; + } + } + args.push(path); + if (request.body != null) { + args.push('--input', '-'); + } + return runGhApi(repoRoot, args, request.body); + }; + + return { + /** + * @template T + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + * path: string, + * query?: Readonly>, + * body?: unknown, + * paginate?: boolean, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + // --paginate may emit concatenated JSON arrays; join them. + if (request.paginate && text.includes(']\n[')) { + const chunks = text + .split(/\n(?=\[)/) + .map((chunk) => JSON.parse(chunk)) + .flat(); + return /** @type {T} */ (chunks); + } + return /** @type {T} */ (JSON.parse(text)); + }, + requestText, + }; +}; + +module.exports = { + GH_NOT_FOUND_CODE, + createGhGitHubTransport, + createGhNotFoundError, + getGhCommand, +}; diff --git a/electron/git-state/github-history/github-review-history.cjs b/electron/git-state/github-history/github-review-history.cjs new file mode 100644 index 00000000..cb98f7f9 --- /dev/null +++ b/electron/git-state/github-history/github-review-history.cjs @@ -0,0 +1,285 @@ +// @ts-check + +/** + * Local GitHub review-history adapter over gh transport + @nkzw/codiff-github. + * + * Process spawning (gh, git fetch) stays here. Pure force-push timeline and + * compare/evolution projection live in the package. + */ + +const { spawn } = require('node:child_process'); +const { createGhGitHubTransport } = require('./gh-github-transport.cjs'); +const { gitOrEmpty } = require('../common.cjs'); +const { readRangeState } = require('../commit.cjs'); +const { loadGitHubHistory } = require('../../github-history-bridge.cjs'); + +/** + * @typedef {import('../../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../../core/types.ts').ReviewEvolutionUnit} ReviewEvolutionUnit + * @typedef {import('../../../core/types.ts').ReviewSource} ReviewSource + * @typedef {import('../../../core/types.ts').ReviewVersionOption} ReviewVersionOption + * @typedef {Extract} PullRequestSource + */ + +/** + * @param {PullRequestSource} source + */ +const assertGitHubSource = (source) => { + if (source.provider && source.provider !== 'github') { + throw new Error('GitHub review history requires a GitHub pull request source.'); + } + if (!source.owner?.trim() || !source.repo?.trim()) { + throw new Error('GitHub review history requires owner and repo on the pull request source.'); + } + if (!source.number || !Number.isInteger(source.number) || source.number <= 0) { + throw new Error('GitHub review history requires a pull request number.'); + } + return { + headSha: source.headSha ?? null, + number: source.number, + owner: source.owner, + repo: source.repo, + }; +}; + +/** + * @param {string} repoRoot + * @param {ReadonlyArray} args + * @returns {Promise} + */ +const runGit = (repoRoot, args) => + new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** @type {Array} */ + const stdout = []; + /** @type {Array} */ + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject( + new Error( + Buffer.concat(stderr).toString('utf8').trim() || `git exited with code ${code}.`, + ), + ); + } + }); + }); + +/** + * @param {string} sha + */ +const shortSha = (sha) => sha.slice(0, 7); + +/** + * @param {string} repoRoot + * @param {string} sha + */ +const ensureCommitAvailable = async (repoRoot, sha) => { + const existing = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) + ).trim(); + if (existing) { + return existing; + } + try { + await runGit(repoRoot, ['fetch', '--no-tags', 'origin', sha]); + } catch { + try { + await runGit(repoRoot, ['fetch', '--no-tags', '--depth=1', 'origin', sha]); + } catch { + // Fall through. + } + } + const after = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) + ).trim(); + if (!after) { + throw new Error( + `Commit ${shortSha(sha)} is not available locally. Fetch the pull request refs or run \`git fetch origin ${sha}\`.`, + ); + } + return after; +}; + +/** + * @param {string} repoRoot + * @param {string} base + * @param {string} head + */ +const readCommitStack = async (repoRoot, base, head) => { + const raw = await gitOrEmpty(repoRoot, [ + 'log', + '--format=%H%x00%P%x00%an%x00%aI%x00%s', + `${base}..${head}`, + ]); + if (!raw.trim()) { + return []; + } + return raw + .trim() + .split('\n') + .map((line) => { + const [sha, parents, authorName, authoredAt, subject] = line.split('\0'); + return { + authorName: authorName || 'Unknown', + authoredAt: authoredAt || new Date(0).toISOString(), + parentIds: parents ? parents.split(' ').filter(Boolean) : [], + sha, + shortSha: shortSha(sha), + subject: subject || sha.slice(0, 7), + }; + }) + .reverse(); +}; + +/** + * @param {string} repoRoot + */ +/** + * @param {string} repoRoot + * @param {string} sha + */ +const readCommitMeta = async (repoRoot, sha) => { + const raw = ( + await gitOrEmpty(repoRoot, ['log', '-1', '--format=%H%x00%P%x00%an%x00%aI%x00%s', sha]) + ).trim(); + if (!raw) { + throw new Error(`Commit ${shortSha(sha)} metadata is unavailable.`); + } + const [fullSha, parents, authorName, authoredAt, subject] = raw.split('\0'); + return { + authorName: authorName || 'Unknown', + authoredAt: authoredAt || new Date(0).toISOString(), + parentIds: parents ? parents.split(' ').filter(Boolean) : [], + sha: fullSha || sha, + shortSha: shortSha(fullSha || sha), + subject: subject || shortSha(fullSha || sha), + }; +}; + +/** + * @param {string} repoRoot + * @param {string} ancestor + * @param {string} descendant + */ +const isAncestor = async (repoRoot, ancestor, descendant) => { + const code = await new Promise((resolve) => { + const child = spawn('git', ['merge-base', '--is-ancestor', ancestor, descendant], { + cwd: repoRoot, + stdio: ['ignore', 'ignore', 'ignore'], + }); + child.on('error', () => resolve(1)); + child.on('close', (exitCode) => resolve(exitCode ?? 1)); + }); + return code === 0; +}; + +/** + * @param {string} repoRoot + */ +const createLocalGit = (repoRoot) => ({ + ensureCommit: (/** @type {string} */ sha) => ensureCommitAvailable(repoRoot, sha), + /** + * @param {string} ancestor + * @param {string} descendant + */ + isAncestor: (ancestor, descendant) => isAncestor(repoRoot, ancestor, descendant), + /** + * @param {string} left + * @param {string} right + */ + mergeBase: async (left, right) => { + const base = (await gitOrEmpty(repoRoot, ['merge-base', left, right])).trim(); + if (!base) { + throw new Error(`No merge base is available for ${shortSha(left)} and ${shortSha(right)}.`); + } + return base; + }, + /** + * @param {string} sha + */ + readCommitDiff: async (sha) => { + const parent = ( + await gitOrEmpty(repoRoot, ['rev-parse', '--verify', '--quiet', `${sha}^`]) + ).trim(); + if (!parent) { + const state = await readRangeState(repoRoot, `${sha}^`, sha, false); + return state.files; + } + const state = await readRangeState(repoRoot, parent, sha, false); + return state.files; + }, + /** + * @param {string} sha + */ + readCommitMeta: (sha) => readCommitMeta(repoRoot, sha), + /** + * @param {string} base + * @param {string} head + */ + readCommitStack: (base, head) => readCommitStack(repoRoot, base, head), + /** + * @param {string} base + * @param {string} head + * @param {boolean} symmetric + */ + readRangeFiles: async (base, head, symmetric) => { + const state = await readRangeState(repoRoot, base, head, symmetric); + return state.files; + }, +}); + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + */ +const listGitHubReviewVersions = async (repoRoot, source) => { + const pull = assertGitHubSource(source); + const transport = createGhGitHubTransport({ repoRoot }); + const github = /** @type {any} */ (await loadGitHubHistory()); + return github.listGitHubReviewVersions({ git: createLocalGit(repoRoot), pull, transport }); +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {{ fromId: string, toId: string }} range + * @param {ReadonlyArray} versions + */ +const compareGitHubReviewVersions = async (repoRoot, source, range, versions) => { + const pull = assertGitHubSource(source); + const github = await loadGitHubHistory(); + return github.compareGitHubReviewVersions({ + git: createLocalGit(repoRoot), + pull, + range, + versions, + }); +}; + +/** + * @param {string} repoRoot + * @param {ReviewEvolutionUnit} unit + * @returns {Promise>} + */ +const loadGitHubVersionCommitUnitDiff = async (repoRoot, unit) => { + const github = await loadGitHubHistory(); + return github.loadGitHubVersionCommitUnitDiff({ + git: createLocalGit(repoRoot), + unit, + }); +}; + +module.exports = { + compareGitHubReviewVersions, + listGitHubReviewVersions, + loadGitHubVersionCommitUnitDiff, +}; diff --git a/electron/git-state/gitlab-review-history.cjs b/electron/git-state/gitlab-review-history.cjs new file mode 100644 index 00000000..7ffe7e57 --- /dev/null +++ b/electron/git-state/gitlab-review-history.cjs @@ -0,0 +1,324 @@ +// @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, + * } | undefined} 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 = /** @type {any} */ (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 {import('../../core/types.ts').ReviewVersionCompareEndpoint | undefined} endpoint + * @param {string | undefined} fallbackId + * @param {ReadonlyArray} versions + */ +const toRequestedCompareEndpoint = ( + /** @type {import('../../core/types.ts').ReviewVersionCompareEndpoint | undefined} */ endpoint, + /** @type {string | undefined} */ fallbackId, + versions, +) => { + if (!endpoint) { + if (!fallbackId) { + throw new Error('A review comparison endpoint is required.'); + } + return toCompareEndpoint(fallbackId, versions); + } + if (endpoint.kind === 'base') { + return { kind: 'mr-base' }; + } + if (endpoint.kind === 'version') { + return toCompareEndpoint(endpoint.id, versions); + } + if (endpoint.kind === 'head-sha') { + return { headSha: endpoint.sha, kind: 'head-sha' }; + } + return { commentId: endpoint.commentId.replace(/^gitlab:/, ''), kind: 'comment-position' }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {{ + * from?: import('../../core/types.ts').ReviewVersionCompareEndpoint, + * fromId?: string, + * to?: import('../../core/types.ts').ReviewVersionCompareEndpoint, + * 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 = /** @type {any} */ (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 = toRequestedCompareEndpoint(range.from, range.fromId, versions); + const toEndpoint = toRequestedCompareEndpoint(range.to, 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/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs new file mode 100644 index 00000000..dfc4a708 --- /dev/null +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -0,0 +1,204 @@ +// @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 apiPath = url.pathname.replace(/^\/api\/v4(?=\/|$)/, ''); + const resource = `${apiPath || '/'}${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..2bbbacaa 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -740,7 +740,13 @@ const readMergeRequestImageContent = async (launchPath, source, requestedPath) = } }; +const { + createGlabGitLabTransport, + getGlabCommand: getGlabCommandFromTransport, +} = require('./glab-gitlab-transport.cjs'); + module.exports = { + createGlabGitLabTransport, createGitLabPosition, createMergeRequestFetchRefspecs, listMergeRequestHistory, diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index 8040ad04..f1d1e138 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -316,12 +316,26 @@ const readGitHubImageFile = async (repoRoot, repository, ref, path) => { }; /** @param {string} repoRoot @param {PullRequestReference} pullRequest */ -const readPullRequestDiff = async (repoRoot, pullRequest) => - ghApi(repoRoot, [ - '-H', - 'Accept: application/vnd.github.v3.diff', - `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, - ]); +const readPullRequestDiff = async (repoRoot, pullRequest) => { + try { + return await ghApi(repoRoot, [ + '-H', + 'Accept: application/vnd.github.v3.diff', + `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}`, + ]); + } catch (error) { + // GitHub rejects the aggregate diff above 20,000 lines, but the paginated + // pull-request files response still provides per-file patches. Let the + // caller use those patches instead of failing the entire repository load. + if ( + error instanceof Error && + /diff exceeded the maximum number of lines \(20000\).*HTTP 406/i.test(error.message) + ) { + return ''; + } + throw error; + } +}; /** @param {unknown} side */ const fromGitHubReviewSide = (side) => (side === 'LEFT' ? 'deletions' : 'additions'); @@ -998,6 +1012,7 @@ module.exports = { createPullRequestSection, createPullRequestSource, getPullRequestHeadImageSource, + readPullRequestDiff, listPullRequestHistory, normalizeGitHubCommit, normalizeGitHubPullRequestCommit, diff --git a/electron/git-state/review-history.cjs b/electron/git-state/review-history.cjs new file mode 100644 index 00000000..b2146c97 --- /dev/null +++ b/electron/git-state/review-history.cjs @@ -0,0 +1,120 @@ +// @ts-check + +/** + * Provider-neutral local review-history facade. + * Dispatches to GitLab (native versions) or GitHub (force-push heads). + */ + +const { + compareGitLabReviewVersions, + listGitLabReviewVersions, + loadGitLabVersionCommitUnitDiff, +} = require('./gitlab-review-history.cjs'); +const { + compareGitHubReviewVersions, + listGitHubReviewVersions, + loadGitHubVersionCommitUnitDiff, +} = require('./github-history/github-review-history.cjs'); + +/** + * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../core/types.ts').ReviewEvolutionUnit} ReviewEvolutionUnit + * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource + * @typedef {import('../../core/types.ts').ReviewVersionOption} ReviewVersionOption + * @typedef {Extract} PullRequestSource + */ + +/** + * @param {PullRequestSource} source + */ +const isGitLabSource = (source) => source.provider === 'gitlab'; + +/** + * @param {PullRequestSource} source + */ +const isGitHubSource = (source) => + source.provider === 'github' || + (!source.provider && !source.host) || + source.host === 'github.com'; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @returns {Promise<{ versions: ReadonlyArray, warning?: string | null }>} + */ +const listReviewVersions = async (repoRoot, source) => { + if (isGitLabSource(source)) { + const versions = await listGitLabReviewVersions(repoRoot, source); + return { versions, warning: null }; + } + if (isGitHubSource(source)) { + return listGitHubReviewVersions(repoRoot, source); + } + return { + versions: [], + warning: 'Review history is not available for this pull request provider.', + }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {{ + * from?: import('../../core/types.ts').ReviewVersionCompareEndpoint, + * fromId?: string, + * to?: import('../../core/types.ts').ReviewVersionCompareEndpoint, + * toId?: string, + * }} range + * @param {ReadonlyArray} [versions] + */ +const compareReviewVersions = async (repoRoot, source, range, versions) => { + if (isGitLabSource(source)) { + const result = await compareGitLabReviewVersions(repoRoot, source, range); + return { ...result, warning: null }; + } + if (isGitHubSource(source)) { + const resolvedVersions = + versions ?? (await listGitHubReviewVersions(repoRoot, source)).versions; + const endpointId = ( + /** @type {import('../../core/types.ts').ReviewVersionCompareEndpoint | undefined} */ endpoint, + /** @type {string | undefined} */ fallbackId, + ) => { + if (!endpoint) return fallbackId; + if (endpoint.kind === 'version') return endpoint.id; + if (endpoint.kind === 'head-sha') return endpoint.sha; + if (endpoint.kind === 'comment-position') return endpoint.headSha; + return resolvedVersions[0]?.id; + }; + const fromId = endpointId(range.from, range.fromId); + const toId = endpointId(range.to, range.toId); + if (!fromId || !toId) { + throw new Error('GitHub review comparison endpoints could not be resolved.'); + } + return compareGitHubReviewVersions(repoRoot, source, { fromId, toId }, resolvedVersions); + } + throw new Error('Review version compare is not available for this pull request provider.'); +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {ReviewEvolutionUnit} unit + * @returns {Promise>} + */ +const loadReviewVersionUnitDiff = async (repoRoot, source, unit) => { + if (isGitLabSource(source)) { + return loadGitLabVersionCommitUnitDiff(repoRoot, source, unit); + } + if (isGitHubSource(source)) { + return loadGitHubVersionCommitUnitDiff(repoRoot, unit); + } + throw new Error('Review version unit diffs are not available for this pull request provider.'); +}; + +module.exports = { + compareReviewVersions, + isGitHubSource, + isGitLabSource, + listReviewVersions, + loadReviewVersionUnitDiff, +}; diff --git a/electron/github-history-bridge.cjs b/electron/github-history-bridge.cjs new file mode 100644 index 00000000..8493a8e1 --- /dev/null +++ b/electron/github-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-github (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitHubHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../github/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitHubHistory, +}; 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, +}; 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..ec5c3968 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,5 +1,6 @@ // @ts-check +const { createHash } = require('node:crypto'); const { existsSync, readFileSync, writeFileSync } = require('node:fs'); const { basename, dirname, join, relative, resolve } = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -26,11 +27,28 @@ const { submitPullRequestReview, validateRepositoryPath, } = require('./git-state.cjs'); +const { + compareGitLabReviewVersions, + listGitLabReviewVersions, + loadGitLabVersionCommitUnitDiff, +} = require('./git-state/gitlab-review-history.cjs'); +const { + compareReviewVersions, + listReviewVersions, + loadReviewVersionUnitDiff, +} = require('./git-state/review-history.cjs'); +const { + generateReviewWalkthrough: generateReviewWalkthroughShared, +} = require('./generate-review-walkthrough-bridge.cjs'); const { normalizeOpenAIModel } = require('./codex.cjs'); const { normalizeClaudeModel } = require('./claude.cjs'); const { normalizeOpenCodeModel, renderOpenCodeCommand } = require('./opencode.cjs'); const { createWalkthroughCommit } = require('./walkthrough-commit.cjs'); const { diagnoseWalkthroughMismatch } = require('./walkthrough-diagnosis.cjs'); +const { + narrativeWalkthroughResponseSchema, + versionCommitOverviewResponseSchema, +} = require('./narrative-walkthrough-schema.cjs'); const { readCommitMessageReply } = require('./walkthrough-commit-message.cjs'); const { normalizePiModel } = require('./pi.cjs'); const { @@ -1420,7 +1438,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 +1471,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 +1516,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, @@ -1625,6 +1643,357 @@ ipcMain.handle('codiff:getRepositoryHistory', async (event, limit, source) => { return listRepositoryHistory(repositoryPath, limit, source); }); +ipcMain.handle('codiff:getGitLabReviewVersions', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return listGitLabReviewVersions(repositoryPath, request.source); +}); + +ipcMain.handle('codiff:getGitLabReviewVersionCompare', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return compareGitLabReviewVersions(repositoryPath, request.source, { + fromId: request.fromId, + toId: request.toId, + }); +}); + +ipcMain.handle('codiff:getGitLabReviewVersionUnitDiff', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return loadGitLabVersionCommitUnitDiff(repositoryPath, request.source, request.unit); +}); + +ipcMain.handle('codiff:getReviewVersions', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return listReviewVersions(repositoryPath, request.source); +}); + +ipcMain.handle('codiff:getReviewVersionCompare', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return compareReviewVersions(repositoryPath, request.source, { + ...(request.from ? { from: request.from } : {}), + ...(request.fromId ? { fromId: request.fromId } : {}), + ...(request.to ? { to: request.to } : {}), + ...(request.toId ? { toId: request.toId } : {}), + }); +}); + +ipcMain.handle('codiff:getReviewVersionUnitDiff', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + return loadReviewVersionUnitDiff(repositoryPath, request.source, request.unit); +}); + +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {import('../core/types.ts').GenerateLocalReviewWalkthroughRequest} request + * @param {ReturnType} agent + * @param {ReturnType} agentOptions + */ +const getLocalReviewWalkthroughCacheKey = (state, request, agent, agentOptions) => + `local-review:${createHash('sha256') + .update( + JSON.stringify({ + agent: agent.id, + comparison: request.versionCompare ?? null, + headSha: state.source.type === 'pull-request' ? (state.source.headSha ?? null) : null, + model: agent.normalizeModel(agentOptions.model), + prompt: config.settings.walkthroughPrompt, + source: + state.source.type === 'pull-request' + ? { + number: state.source.number ?? null, + provider: state.source.provider ?? null, + url: state.source.url, + } + : state.source, + structure: request.structure ?? 'auto', + unitId: request.unitId ?? null, + version: 1, + }), + ) + .digest('hex')}`; + +ipcMain.handle('codiff:getStoredReviewWalkthrough', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + const source = request?.source; + if (!source || source.type !== 'pull-request') { + return { status: 'missing' }; + } + const state = await readRepositoryStateWithConfig(repositoryPath, source); + const agent = resolveWindowAgent(event.sender.id); + const agentOptions = getAgentOptions(agent); + const cacheKey = getLocalReviewWalkthroughCacheKey(state, request, agent, agentOptions); + const walkthrough = readStoredWalkthrough(cacheKey); + return walkthrough ? { status: 'ready', walkthrough } : { status: 'missing' }; +}); + +ipcMain.handle('codiff:generateReviewWalkthrough', async (event, request) => { + const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); + const progressGeneration = (walkthroughProgressGenerations.get(event.sender.id) || 0) + 1; + walkthroughProgressGenerations.set(event.sender.id, progressGeneration); + const reportProgress = createWalkthroughProgressReporter( + event.sender, + () => walkthroughProgressGenerations.get(event.sender.id) === progressGeneration, + ); + /** @param {import('../core/types.ts').WalkthroughGenerationProgress} progress */ + const reportGenerationProgress = (progress) => reportProgress(progress); + const source = request?.source; + if (!source || source.type !== 'pull-request') { + return { reason: 'A pull request source is required.', status: 'failed' }; + } + + try { + const agent = resolveWindowAgent(event.sender.id); + const agentOptions = getAgentOptions(agent); + reportGenerationProgress({ + phase: 'preparing', + summary: 'Loading review state.', + }); + const wholeState = await readRepositoryStateWithConfig(repositoryPath, source); + const cacheKey = getLocalReviewWalkthroughCacheKey(wholeState, request, agent, agentOptions); + if (!request?.force) { + const cached = readStoredWalkthrough(cacheKey); + if (cached) { + return { status: 'ready', walkthrough: cached }; + } + } + + /** @type {Record} */ + const byUnitId = {}; + /** @type {any} */ + let evolution = null; + /** @type {import('../core/types.ts').ReviewPlan | null} */ + let plan = null; + /** @type {any} */ + let versionCompare = null; + /** @type {any} */ + let promptOptions = {}; + + if (request?.versionCompare?.fromId && request?.versionCompare?.toId) { + reportGenerationProgress({ + phase: 'preparing', + summary: 'Comparing the selected versions.', + }); + const compared = await compareReviewVersions(repositoryPath, source, { + fromId: request.versionCompare.fromId, + toId: request.versionCompare.toId, + }); + versionCompare = compared.versionCompare; + evolution = compared.versionCommitEvolution; + // Use compare files as the whole state for version-scoped generation. + wholeState.files = versionCompare.files; + promptOptions = { + versionCompareRange: { + fromLabel: versionCompare.from.range.head.label.text, + structure: + request.structure === 'commit-by-commit' || request.structure === 'units' + ? 'commit-by-commit' + : request.structure === 'whole-diff' + ? 'whole-diff' + : undefined, + toLabel: versionCompare.to.range.head.label.text, + }, + }; + + if ( + request.structure === 'commit-by-commit' || + request.structure === 'units' || + (request.structure === 'auto' && + evolution?.recommendation?.suggestedStructure === 'commit-by-commit') + ) { + /** @type {Array} */ + const units = (evolution?.units || []).filter( + /** @param {any} unit */ (unit) => + unit.reviewable && (!request.unitId || unit.id === request.unitId), + ); + /** @type {Array} */ + const progressUnits = units.map( + /** @param {any} unit */ (unit) => { + const commit = unit.after || unit.commit || unit.before; + return { + id: unit.id, + label: commit ? `${commit.shortSha} ${commit.subject}` : unit.id, + status: 'pending', + }; + }, + ); + const reportUnitPreparation = (/** @type {string} */ summary) => { + reportGenerationProgress({ + completed: progressUnits.filter( + (unit) => unit.status === 'ready' || unit.status === 'failed', + ).length, + phase: 'preparing', + summary, + total: progressUnits.length, + units: progressUnits.map((unit) => ({ ...unit })), + }); + }; + reportUnitPreparation(`Preparing ${units.length} commit diffs.`); + let nextUnitIndex = 0; + const materializeUnit = async () => { + while (true) { + const index = nextUnitIndex++; + if (index >= units.length) { + return; + } + const unit = units[index]; + const progressUnit = progressUnits[index]; + progressUnits[index] = { + ...progressUnit, + detail: 'Loading commit diff…', + status: 'generating', + }; + reportUnitPreparation(`Preparing ${progressUnit.label}.`); + try { + const files = await loadReviewVersionUnitDiff(repositoryPath, source, unit); + byUnitId[unit.id] = { + ...wholeState, + files, + }; + progressUnits[index] = { ...progressUnits[index], status: 'ready' }; + reportUnitPreparation(`Prepared ${progressUnit.label}.`); + } catch { + progressUnits[index] = { + ...progressUnits[index], + detail: 'Commit diff could not be loaded.', + status: 'failed', + }; + reportUnitPreparation(`Could not prepare ${progressUnit.label}.`); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(3, units.length) }, () => materializeUnit()), + ); + } + } else if ( + request.structure === 'commit-by-commit' || + request.structure === 'units' || + request.unitId + ) { + const history = await listRepositoryHistory(repositoryPath, 200, source); + const commitEntries = history.entries.filter((entry) => entry.scope !== 'base'); + const units = + /** @type {Array>} */ ( + commitEntries.map((entry, order) => ({ + commit: { + authoredAt: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + parentIds: entry.parents, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + subject: entry.subject, + }, + id: `commit:${entry.ref}`, + kind: 'commit', + order, + reviewable: true, + })) + ).filter((unit) => !request.unitId || unit.id === request.unitId); + plan = { structure: 'units', units }; + reportGenerationProgress({ + completed: 0, + phase: 'preparing', + summary: `Preparing ${units.length} commit diffs.`, + total: units.length, + units: units.map((unit) => ({ + id: unit.id, + label: `${unit.commit.shortSha} ${unit.commit.subject}`, + status: 'pending', + })), + }); + await Promise.all( + units.map(async (unit) => { + const commitState = await readRepositoryStateWithConfig(repositoryPath, { + ref: unit.commit.sha, + type: 'commit', + }); + byUnitId[unit.id] = commitState; + }), + ); + } + + const result = await generateReviewWalkthroughShared({ + agent: agent.id, + evolution, + plan, + promptOptions, + runModel: async ({ prompt, state }) => { + const timeoutMs = agent.defaultTimeoutMs || 600_000; + const response = await agent.run( + state.root, + prompt, + narrativeWalkthroughResponseSchema, + 'walkthrough.json', + `${agent.label} walkthrough timed out.`, + { + ...agentOptions, + timeoutMs, + }, + ); + if (response && typeof response === 'object') { + return { draft: response }; + } + const text = typeof response === 'string' ? response : String(response ?? ''); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const raw = (fenced?.[1] || text).trim(); + try { + return { draft: JSON.parse(raw) }; + } catch { + return { draft: raw }; + } + }, + runOverviewModel: async ({ prompt }) => { + const timeoutMs = agent.defaultTimeoutMs || 600_000; + const response = await agent.run( + wholeState.root, + prompt, + versionCommitOverviewResponseSchema, + 'walkthrough-overview.json', + `${agent.label} walkthrough overview timed out.`, + { + ...agentOptions, + timeoutMs, + }, + ); + const overview = /** @type {{ focus?: unknown }} */ (response); + if (typeof overview?.focus === 'string') { + return { focus: overview.focus }; + } + throw new Error('The walkthrough overview did not include focus text.'); + }, + states: { + byUnitId, + whole: wholeState, + }, + structure: request?.structure ?? 'auto', + onProgress: reportGenerationProgress, + unitConcurrency: 3, + }); + + if (result.status === 'ready') { + try { + writeStoredWalkthrough(cacheKey, result.walkthrough); + } catch { + // Persistence is optional; generation remains successful if the store is unavailable. + } + return { status: 'ready', walkthrough: result.walkthrough }; + } + return { reason: result.reason, status: 'failed' }; + } catch (error) { + const agent = resolveWindowAgent(event.sender.id); + if (agent.isNotFoundError?.(error)) { + return { + code: agent.notFoundCode, + reason: error instanceof Error ? error.message : String(error), + status: 'unavailable', + }; + } + return { + reason: error instanceof Error ? error.message : String(error), + status: 'failed', + }; + } +}); + ipcMain.handle('codiff:getGitIdentity', async (event) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); return readGitIdentity(repositoryPath); diff --git a/electron/narrative-walkthrough-schema.cjs b/electron/narrative-walkthrough-schema.cjs index 4f69496f..a4c3573c 100644 --- a/electron/narrative-walkthrough-schema.cjs +++ b/electron/narrative-walkthrough-schema.cjs @@ -208,6 +208,14 @@ const narrativeWalkthroughResponseSchema = strictResponseSchema( narrativeWalkthroughGenerationSchema, ); +const versionCommitOverviewResponseSchema = { + additionalProperties: false, + properties: { + focus: { type: 'string' }, + }, + required: ['focus'], + type: 'object', +}; module.exports = { AGENTS, CHANGE_TYPES, @@ -217,5 +225,6 @@ module.exports = { MAX_WALKTHROUGH_STOPS, MAX_HUNKS_PER_WALKTHROUGH_GROUP, narrativeWalkthroughResponseSchema, + versionCommitOverviewResponseSchema, narrativeWalkthroughSchema, }; diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index a95a1963..ea1462fa 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,12 @@ 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 +457,27 @@ 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 +490,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,41 +798,42 @@ 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. - -${buildWalkthroughSizingGuidance(state)} + 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} -${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) - .prompt; + ( + await buildNarrativeWalkthroughRequest( + state, + context, + agentLabel, + customPrompt, + previousWalkthrough, + ) + ).prompt; /** * Cache identity for the exact model input. The previous walkthrough is @@ -857,8 +846,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 +883,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 +904,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/preload.cjs b/electron/preload.cjs index 8734882c..d63ad9d7 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -24,6 +24,8 @@ const codiff = { getConfig: () => ipcRenderer.invoke('codiff:getConfig'), decreaseCodeFontSize: () => ipcRenderer.invoke('codiff:decreaseCodeFontSize'), getDiffSectionContent: (request) => ipcRenderer.invoke('codiff:getDiffSectionContent', request), + generateReviewWalkthrough: (request) => + ipcRenderer.invoke('codiff:generateReviewWalkthrough', request), getFeatureFlags: () => ipcRenderer.invoke('codiff:getFeatureFlags'), getDiffImageContent: (request) => ipcRenderer.invoke('codiff:getDiffImageContent', request), getGitIdentity: () => ipcRenderer.invoke('codiff:getGitIdentity'), @@ -34,6 +36,19 @@ const codiff = { getRepositoryHistory: (limit, source) => ipcRenderer.invoke('codiff:getRepositoryHistory', limit, source), getRepositoryState: (source) => ipcRenderer.invoke('codiff:getRepositoryState', source), + getGitLabReviewVersionCompare: (request) => + ipcRenderer.invoke('codiff:getGitLabReviewVersionCompare', request), + getGitLabReviewVersions: (request) => + ipcRenderer.invoke('codiff:getGitLabReviewVersions', request), + getGitLabReviewVersionUnitDiff: (request) => + ipcRenderer.invoke('codiff:getGitLabReviewVersionUnitDiff', request), + getReviewVersionCompare: (request) => + ipcRenderer.invoke('codiff:getReviewVersionCompare', request), + getReviewVersions: (request) => ipcRenderer.invoke('codiff:getReviewVersions', request), + getReviewVersionUnitDiff: (request) => + ipcRenderer.invoke('codiff:getReviewVersionUnitDiff', request), + getStoredReviewWalkthrough: (request) => + ipcRenderer.invoke('codiff:getStoredReviewWalkthrough', request), getTerminalHelperStatus: () => ipcRenderer.invoke('codiff:getTerminalHelperStatus'), getNarrativeWalkthrough: (source, options) => ipcRenderer.invoke('codiff:getNarrativeWalkthrough', source, options), @@ -73,6 +88,7 @@ const codiff = { return () => ipcRenderer.removeListener('codiff:findInDiffs', listener); }, onMarkdownDocumentChanged: (callback) => { + /** @param {Electron.IpcRendererEvent} _event @param {Parameters[0]} change */ const listener = (_event, change) => callback(change); ipcRenderer.on('codiff:markdownDocumentChanged', listener); return () => ipcRenderer.removeListener('codiff:markdownDocumentChanged', listener); diff --git a/electron/walkthrough-authoring-bridge.cjs b/electron/walkthrough-authoring-bridge.cjs new file mode 100644 index 00000000..014272ce --- /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, +}; diff --git a/electron/walkthrough-progress.cjs b/electron/walkthrough-progress.cjs index dc0f0457..4d5d6605 100644 --- a/electron/walkthrough-progress.cjs +++ b/electron/walkthrough-progress.cjs @@ -1,21 +1,21 @@ // @ts-check /** - * Keep repeated phases because agent deltas are real activity events. The - * renderer decides whether an event changes the visible stage. + * Forward semantic walkthrough stages and detailed unit progress. The renderer + * decides which events change the visible stage. * * @param {Pick} webContents * @param {() => boolean} [isCurrent] */ const createWalkthroughProgressReporter = (webContents, isCurrent = () => true) => { - /** @param {import('../core/types.ts').WalkthroughProgressPhase} phase */ - return (phase) => { + /** @param {import('../core/types.ts').WalkthroughProgressEvent['phase'] | import('../core/types.ts').WalkthroughGenerationProgress} update */ + return (update) => { if (webContents.isDestroyed() || !isCurrent()) { return; } /** @type {import('../core/types.ts').WalkthroughProgressEvent} */ - const progress = { phase }; + const progress = typeof update === 'string' ? { phase: update } : { generation: update }; webContents.send('codiff:walkthroughProgress', progress); }; }; diff --git a/github/__tests__/history.test.ts b/github/__tests__/history.test.ts new file mode 100644 index 00000000..e1e6d27a --- /dev/null +++ b/github/__tests__/history.test.ts @@ -0,0 +1,391 @@ +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, + compareGitHubReviewVersions, + 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('compareGitHubReviewVersions uses a direct head-to-head diff', async () => { + const base = '0'.repeat(40); + const before = 'a'.repeat(40); + const after = 'b'.repeat(40); + const readRangeFilesCalls: Array<[string, string, boolean]> = []; + const versions = [ + { + createdAt: '2026-01-01T00:00:00.000Z', + id: before, + range: { + base: { commitId: base, label: { kind: 'commit' as const, text: 'base' } }, + head: { commitId: before, label: { kind: 'version' as const, text: 'before' } }, + }, + }, + { + createdAt: '2026-01-02T00:00:00.000Z', + id: after, + range: { + base: { commitId: base, label: { kind: 'commit' as const, text: 'base' } }, + head: { commitId: after, label: { kind: 'version' as const, text: 'after' } }, + }, + }, + ]; + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async () => false, + mergeBase: async () => base, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => ({ + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha, + shortSha: sha.slice(0, 7), + subject: sha.slice(0, 7), + }), + readCommitStack: async () => [], + readRangeFiles: async (from, to, symmetric) => { + readRangeFilesCalls.push([from, to, symmetric]); + return []; + }, + }; + + const result = await compareGitHubReviewVersions({ + git, + pull: { number: 12, owner: 'nkzw-tech', repo: 'codiff' }, + range: { fromId: before, toId: after }, + versions, + }); + + expect(result.versionCompare.analysis.summary.empty).toBe(true); + expect(readRangeFilesCalls[0]).toEqual([before, after, false]); +}); + +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({ + git: { + ensureCommit: async (sha) => sha, + isAncestor: async () => false, + mergeBase: async (_base, head) => (head === before ? '1'.repeat(40) : base), + readCommitDiff: async () => [], + readCommitMeta: async () => { + throw new Error('unused'); + }, + readCommitStack: async () => [], + readRangeFiles: async () => [], + }, + 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); + expect(versions.map((version) => version.range.base.commitId)).toEqual([ + '1'.repeat(40), + base, + base, + ]); +}); + +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; + }, + mergeBase: async () => oldBase, + 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, + mergeBase: async () => newBase, + 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, + mergeBase: async () => fromBase, + 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..6719aa29 --- /dev/null +++ b/github/src/history.ts @@ -0,0 +1,747 @@ +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; + /** Merge base of two commits, used to recover each historical head's effective target base. */ + mergeBase(left: string, right: 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 ({ + git, + pull, + transport, +}: { + git?: GitHubHistoryGit; + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): Promise<{ + versions: ReadonlyArray; + warning: string | null; +}> => { + const { currentBase, currentHead, events, warning } = await readForcePushTimeline({ + pull, + transport, + }); + 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 bases = await Promise.all( + heads.map(async (head) => { + if (!currentBase || !git) { + return currentBase || 'unknown-base'; + } + try { + await Promise.all([git.ensureCommit(currentBase), git.ensureCommit(head.sha)]); + return await git.mergeBase(currentBase, head.sha); + } catch { + return currentBase; + } + }), + ); + + const versions = heads.map((head, index) => { + const baseSha = bases[index]!; + return 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)); + } + } + + const files = await git.readRangeFiles(fromHead, toHead, false); + + 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, false); + } + 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/gitlab/__tests__/history-transport.test.ts b/gitlab/__tests__/history-transport.test.ts index 3fc167cb..b52a9d6f 100644 --- a/gitlab/__tests__/history-transport.test.ts +++ b/gitlab/__tests__/history-transport.test.ts @@ -1,13 +1,16 @@ import { expect, test } from 'vite-plus/test'; import { createFakeGitLabTransport, + fetchGitLabMergeRequestVersionCompare, fetchGitLabMergeRequestVersions, projectCommitEvolution, projectReviewPlan, - projectVersionCompare, toGitLabDiffIdentity, } from '../src/index.ts'; -import { matchVersionCommitStacks, createCommitPatchSignature } from '../src/version-commit-evolution.ts'; +import { + matchVersionCommitStacks, + createCommitPatchSignature, +} from '../src/version-commit-evolution.ts'; test('loads merge request versions through the injected transport', async () => { const transport = createFakeGitLabTransport([ @@ -15,18 +18,18 @@ test('loads merge request versions through the injected transport', async () => 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', + head_commit_sha: 'b'.repeat(40), + id: 2, + start_commit_sha: 'a'.repeat(40), }, { - 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', + head_commit_sha: 'c'.repeat(40), + id: 1, + start_commit_sha: 'a'.repeat(40), }, ], }, @@ -45,6 +48,80 @@ test('loads merge request versions through the injected transport', async () => expect(transport.calls[0]?.path).toContain('/merge_requests/7/versions'); }); +test('uses fetched discussion anchors with supplied unrelated comment anchors', async () => { + const baseSha = 'a'.repeat(40); + const commentHeadSha = 'b'.repeat(40); + const latestHeadSha = 'c'.repeat(40); + const transport = createFakeGitLabTransport([ + { + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + response: [ + { + base_commit_sha: baseSha, + created_at: '2026-01-02T00:00:00.000Z', + head_commit_sha: latestHeadSha, + id: 2, + start_commit_sha: baseSha, + }, + { + base_commit_sha: baseSha, + created_at: '2026-01-01T00:00:00.000Z', + head_commit_sha: commentHeadSha, + id: 1, + start_commit_sha: baseSha, + }, + ], + }, + { + path: '/api/v4/projects/group%2Fproject/merge_requests/7/discussions', + response: [ + { + notes: [ + { + id: 42, + position: { + base_sha: baseSha, + head_sha: commentHeadSha, + new_line: 1, + new_path: 'src/app.ts', + start_sha: baseSha, + }, + }, + ], + }, + ], + }, + { + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions/1', + response: { diffs: [] }, + }, + { + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions/2', + response: { diffs: [] }, + }, + ]); + + const comparison = await fetchGitLabMergeRequestVersionCompare({ + comments: [ + { + commentId: 'gitlab:7', + filePath: 'src/unrelated.ts', + position: { baseSha, headSha: latestHeadSha, startSha: baseSha }, + }, + ], + from: { commentId: 'gitlab:42', kind: 'comment-position' }, + iid: 7, + projectPath: 'group/project', + to: { kind: 'mr-version', versionId: '2' }, + transport, + }); + + expect(comparison.range.from.headSha).toBe(commentHeadSha); + expect(transport.calls.some((call) => call.path.endsWith('/merge_requests/7/discussions'))).toBe( + true, + ); +}); + test('projects algorithm evolution into Core review plans', async () => { const from = { baseSha: 'a'.repeat(40), @@ -105,9 +182,12 @@ test('projects algorithm evolution into Core review plans', async () => { to, }); const projected = projectCommitEvolution(evolution); - expect(projected.units.some((unit) => unit.kind === 'revised' || unit.kind === 'rewritten-same-patch' || unit.kind === 'retained')).toBe( - true, - ); + 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/fixtures/version-compare-cases.ts b/gitlab/fixtures/version-compare-cases.ts index c10b9971..c247ba5d 100644 --- a/gitlab/fixtures/version-compare-cases.ts +++ b/gitlab/fixtures/version-compare-cases.ts @@ -1,4 +1,4 @@ -import type { MergeRequestVersionRef, VersionPatchFile } from '../version-compare.ts'; +import type { MergeRequestVersionRef, VersionPatchFile } from '../src/version-compare.ts'; /** Synthetic fixtures used for version-comparison algorithm coverage without live GitLab. */ @@ -29,7 +29,7 @@ export const pureRebaseFiles = { newPath: 'src/app.ts', oldPath: 'src/app.ts', patchBody: sameLogicalPatch, - status: 'modified', + status: 'modified' as const, }, ], to: [ @@ -38,7 +38,7 @@ export const pureRebaseFiles = { 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', + status: 'modified' as const, }, ], } satisfies { @@ -53,13 +53,13 @@ export const rebasePlusEditFiles = { 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', + status: 'modified' as const, }, { newPath: 'README.md', oldPath: 'README.md', patchBody: '@@ -1 +1 @@\n-hello\n+hello world\n', - status: 'modified', + status: 'modified' as const, }, ], } satisfies { @@ -74,7 +74,7 @@ export const conflictResolutionFiles = { 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', + status: 'modified' as const, }, ], } satisfies { diff --git a/gitlab/src/history.ts b/gitlab/src/history.ts index 8d99dfd0..fe8ad5d7 100644 --- a/gitlab/src/history.ts +++ b/gitlab/src/history.ts @@ -1,14 +1,3 @@ -/** - * Read-side GitLab review-history adapter over {@link GitLabTransport}. - */ -import type { - ChangedFile, - DiffComparisonAnalysis, - DiffComparisonView, - ReviewCommitEvolution, - ReviewPlan, - ReviewVersionOption, -} from '@nkzw/codiff-core/types'; import { commitRevisionLabel, diffComparison, @@ -20,6 +9,17 @@ import { revisionRef, versionRevisionLabel, } from '@nkzw/codiff-core'; +/** + * Read-side GitLab review-history adapter over {@link GitLabTransport}. + */ +import type { + ChangedFile, + DiffComparisonAnalysis, + DiffComparisonView, + ReviewCommitEvolution, + ReviewPlan, + ReviewVersionOption, +} from '@nkzw/codiff-core/types'; import type { GitLabTransport } from './transport.ts'; import { attributeRebaseDrivers, @@ -126,7 +126,9 @@ const asString = (value: unknown, fallback = '') => (typeof value === 'string' ? const asNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null; const trimmedString = (value: unknown) => { - if (typeof value !== 'string') return null; + if (typeof value !== 'string') { + return null; + } const trimmed = value.trim(); return trimmed ? trimmed : null; }; @@ -180,16 +182,16 @@ const repositoryCommitDiffEndpoint = (projectPath: string, sha: string) => const repositoryCommitEndpoint = (projectPath: string, sha: string) => `${gitLabOrigin}/api/v4/projects/${encodeURIComponent(projectPath)}/repository/commits/${encodeURIComponent(sha)}`; - - - - - +type GitLabTransportRequest = Parameters[0]; const createRequest = ( pathOrUrl: string, - init: { method?: string; headers?: HeadersInit; body?: unknown } = {}, -) => { + init: { + body?: unknown; + headers?: HeadersInit; + method?: GitLabTransportRequest['method']; + } = {}, +): GitLabTransportRequest => { // Accept absolute URLs from legacy helpers and reduce to path+query. let path = pathOrUrl; let query: Record | undefined; @@ -204,23 +206,24 @@ const createRequest = ( path = url.pathname; query = Object.fromEntries(url.searchParams.entries()); } - return { body: init.body, method: (init.method as any) ?? 'GET', path, query }; + return { body: init.body, method: init.method ?? 'GET', path, query }; }; const readJson = async ( transport: GitLabTransport, - request: { path: string; query?: Record; method?: any; body?: unknown }, + request: GitLabTransportRequest, _unavailableMessage: string, -) => transport.request({ - body: request.body, - method: request.method, - path: request.path, - query: request.query, -}); +) => + transport.request({ + body: request.body, + method: request.method, + path: request.path, + query: request.query, + }); const readText = async ( transport: GitLabTransport, - request: { path: string; query?: Record }, + request: Pick, ) => { if (!transport.requestText) { throw new Error('GitLabTransport.requestText is required for raw blob reads.'); @@ -228,10 +231,7 @@ const readText = async ( return transport.requestText({ path: request.path, query: request.query }); }; -const readPages = async ( - transport: GitLabTransport, - url: string, -): Promise> => { +const readPages = async (transport: GitLabTransport, url: string): Promise> => { const first = createRequest(url); if (transport.requestPages) { return transport.requestPages({ path: first.path, query: first.query }); @@ -245,7 +245,9 @@ const readPages = async ( }); const pageValues = asArray(result); values.push(...pageValues); - if (pageValues.length < 100) break; + if (pageValues.length < 100) { + break; + } page += 1; } if (page > maxPages) { @@ -254,14 +256,6 @@ const readPages = async ( return values; }; - - - - - - - - const createPatch = (diff: JsonRecord) => { const oldPath = asString(diff.old_path); const newPath = asString(diff.new_path); @@ -421,35 +415,32 @@ const normalizeMergeRequestVersion = ( // 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, }: { - transport: GitLabTransport; iid: number; projectPath: string; + transport: GitLabTransport; }): 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), - ); + 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, }: { - transport: GitLabTransport; projectPath: string; sha: string; + transport: GitLabTransport; }): Promise> => { const projectPath = validateProjectPath(rawProjectPath); const normalizedSha = sha.trim(); @@ -459,7 +450,7 @@ export const fetchGitLabCommitDiff = async ({ const diffs = await readPages( transport, repositoryCommitDiffEndpoint(projectPath, normalizedSha), - ); + ); return diffs .map((diff, index) => normalizeDiff(diff, 0, normalizedSha, index, 'commit', normalizedSha.slice(0, 12)), @@ -501,22 +492,19 @@ type VersionStatCache = { }; const readGitLabMergeRequestVersions = async ({ - transport, iid, projectPath: rawProjectPath, + transport, }: { - transport: GitLabTransport; iid: number; projectPath: string; + transport: GitLabTransport; }): 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), - ); + const values = await readPages(transport, mergeRequestVersionsEndpoint(projectPath, iid)); return values .map((value, index) => normalizeMergeRequestVersion(value, index)) .filter((version): version is MergeRequestVersionRef => version != null) @@ -528,9 +516,9 @@ const readGitLabMergeRequestVersions = async ({ /** Versions ordered newest → oldest for existing versionCompare endpoint callers. */ export const fetchGitLabMergeRequestVersions = async (args: { - transport: GitLabTransport; iid: number; projectPath: string; + transport: GitLabTransport; }): Promise> => { const versions = await readGitLabMergeRequestVersions(args); return versions.toReversed().map((version, index) => ({ @@ -541,19 +529,19 @@ export const fetchGitLabMergeRequestVersions = async (args: { export const fetchGitLabMergeRequestVersionHistory = async ({ cache, - transport, iid, projectPath, + transport, }: { cache?: VersionStatCache; - transport: GitLabTransport; iid: number; projectPath: string; + transport: GitLabTransport; }): Promise> => { const versions = await readGitLabMergeRequestVersions({ - transport, iid, projectPath, + transport, }); if (versions.length === 0) { return []; @@ -578,9 +566,9 @@ export const fetchGitLabMergeRequestVersionHistory = async ({ // SHAs so this is genuinely v(N-1) → vN (and base → v1). const files = await readCompareFiles({ from: previous.headSha, - transport, projectPath, to: version.headSha, + transport, }); const diffStat = { additions: files.reduce( @@ -630,14 +618,14 @@ export const fetchGitLabMergeRequestVersionHistory = async ({ }; const readMergeRequestVersionFiles = async ({ - transport, iid, projectPath, + transport, versionId, }: { - transport: GitLabTransport; iid: number; projectPath: string; + transport: GitLabTransport; versionId: string; }): Promise> => { const value = await readJson( @@ -652,14 +640,14 @@ const readMergeRequestVersionFiles = async ({ }; const readRepositoryCompare = async ({ from, - transport, projectPath, to, + transport, }: { from: string; - transport: GitLabTransport; projectPath: string; to: string; + transport: GitLabTransport; }): Promise => asRecord( await readJson( @@ -671,9 +659,9 @@ const readRepositoryCompare = async ({ const readCompareFiles = async (args: { from: string; - transport: GitLabTransport; projectPath: string; to: string; + transport: GitLabTransport; }): Promise> => { const value = await readRepositoryCompare(args); return asArray(value.diffs) @@ -729,14 +717,14 @@ const toBaseMovementCommit = (commit: GitLabMergeRequestCommit) => ({ const readBaseMovement = async ({ fromSha, - transport, projectPath, toSha, + transport, }: { fromSha: string; - transport: GitLabTransport; projectPath: string; toSha: string; + transport: GitLabTransport; }): Promise => { const baseRef = (sha: string, value?: unknown) => { const commit = asRecord(value); @@ -774,7 +762,7 @@ const readBaseMovement = async ({ createRequest(repositoryCommitEndpoint(projectPath, toSha)), 'Unable to load the new base commit.', ), - readRepositoryCompare({ from: fromSha, transport, projectPath, to: toSha }), + readRepositoryCompare({ from: fromSha, projectPath, to: toSha, transport }), ]); const from = baseRef(fromSha, fromValue); const to = baseRef(toSha, toValue); @@ -798,9 +786,9 @@ const readBaseMovement = async ({ try { const reverse = await readRepositoryCompare({ from: toSha, - transport, projectPath, to: fromSha, + transport, }); const reverseCommits = asArray(reverse.commits) .map((value) => normalizeMergeRequestCommit(value, projectPath)) @@ -977,18 +965,17 @@ const collectCommentAnchorsFromDiscussions = ( export const fetchGitLabMergeRequestVersionCompare = async ({ comments = [], from: fromEndpoint, - transport, iid, lastReviewed = null, paths, projectPath: rawProjectPath, readCached, to: toEndpoint, + transport, writeCached, }: { comments?: ReadonlyArray; from: VersionCompareEndpoint; - transport: GitLabTransport; iid: number; lastReviewed?: MergeRequestVersionRef | null; paths?: ReadonlyArray; @@ -998,6 +985,7 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ to: MergeRequestVersionRef; }) => Promise | MergeRequestVersionCompare | null; to: VersionCompareEndpoint; + transport: GitLabTransport; writeCached?: (versionCompare: MergeRequestVersionCompare) => Promise | void; }): Promise => { const projectPath = validateProjectPath(rawProjectPath); @@ -1010,21 +998,20 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ toEndpoint.kind === 'comment-position'; const [versions, discussionAnchors] = await Promise.all([ fetchGitLabMergeRequestVersions({ - transport, iid, projectPath, + transport, }), needsCommentAnchors - ? readPages( - transport, - mergeRequestEndpoint(projectPath, iid, '/discussions'), - ).then(collectCommentAnchorsFromDiscussions) + ? 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 resolvedComments = [...comments, ...discussionAnchors]; const from = resolveVersionCompareEndpoint({ comments: resolvedComments, endpoint: fromEndpoint, @@ -1049,9 +1036,9 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ const baseMovementPromise = readBaseMovement({ fromSha: from.baseSha, - transport, projectPath, toSha: to.baseSha, + transport, }); const loadVersionFiles = async (version: MergeRequestVersionRef) => { @@ -1059,9 +1046,9 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ if (listed) { try { return await readMergeRequestVersionFiles({ - transport, iid, projectPath, + transport, versionId: version.id, }); } catch { @@ -1070,9 +1057,9 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ } return readCompareFiles({ from: version.baseSha, - transport, projectPath, to: version.headSha, + transport, }); }; @@ -1122,24 +1109,24 @@ export const fetchGitLabMergeRequestVersionCompare = async ({ const resolveVersionCommitRange = async ({ from, - transport, iid, projectPath, to, + transport, }: { from: VersionCompareEndpoint; - transport: GitLabTransport; iid: number; projectPath: string; to: VersionCompareEndpoint; + transport: GitLabTransport; }) => { const versions = from.kind === 'diff-identity' && to.kind === 'diff-identity' ? [] : await fetchGitLabMergeRequestVersions({ - transport, iid, projectPath, + transport, }); if (versions.length === 0 && (from.kind !== 'diff-identity' || to.kind !== 'diff-identity')) { throw new Error('GitLab did not return merge request versions.'); @@ -1152,14 +1139,14 @@ const resolveVersionCommitRange = async ({ export const fetchGitLabHistoricalCommitStack = async ({ baseSha, - transport, headSha, projectPath: rawProjectPath, + transport, }: { baseSha: string; - transport: GitLabTransport; headSha: string; projectPath: string; + transport: GitLabTransport; }): Promise> => { if (baseSha === headSha) { return []; @@ -1167,9 +1154,9 @@ export const fetchGitLabHistoricalCommitStack = async ({ const projectPath = validateProjectPath(rawProjectPath); const compare = await readRepositoryCompare({ from: baseSha, - transport, projectPath, to: headSha, + transport, }); if (compare.compare_timeout === true || compare.overflow === true) { throw new Error('GitLab truncated the historical commit stack.'); @@ -1183,18 +1170,15 @@ export const fetchGitLabHistoricalCommitStack = async ({ }; const readCommitPatchFiles = async ({ - transport, projectPath, sha, + transport, }: { - transport: GitLabTransport; projectPath: string; sha: string; + transport: GitLabTransport; }) => { - const values = await readPages( - transport, - repositoryCommitDiffEndpoint(projectPath, sha), - ); + const values = await readPages(transport, repositoryCommitDiffEndpoint(projectPath, sha)); return values .map(normalizeVersionPatchFile) .filter((file): file is VersionPatchFile => file != null); @@ -1221,47 +1205,47 @@ type VersionCommitSignatureCache = { export const fetchGitLabMergeRequestVersionCommitEvolution = async ({ cache, from: fromEndpoint, - transport, iid, projectPath: rawProjectPath, to: toEndpoint, + transport, }: { cache?: VersionCommitSignatureCache; from: VersionCompareEndpoint; - transport: GitLabTransport; iid: number; projectPath: string; to: VersionCompareEndpoint; + transport: GitLabTransport; }): Promise => { const projectPath = validateProjectPath(rawProjectPath); const range = await resolveVersionCommitRange({ from: fromEndpoint, - transport, iid, projectPath, to: toEndpoint, + transport, }); const warnings: Array = []; const [oldStackResult, newStackResult, baseStackResult] = await Promise.allSettled([ fetchGitLabHistoricalCommitStack({ baseSha: range.from.baseSha, - transport, headSha: range.from.headSha, projectPath, + transport, }), fetchGitLabHistoricalCommitStack({ baseSha: range.to.baseSha, - transport, headSha: range.to.headSha, projectPath, + transport, }), range.from.baseSha === range.to.baseSha ? Promise.resolve([]) : fetchGitLabHistoricalCommitStack({ baseSha: range.from.baseSha, - transport, headSha: range.to.baseSha, projectPath, + transport, }), ]); const stackCompleteness = { @@ -1345,9 +1329,9 @@ export const fetchGitLabMergeRequestVersionCommitEvolution = async ({ } try { const files = await fetchGitLabCommitDiff({ - transport, projectPath, sha: commit.sha, + transport, }); const signature = await createCommitPatchSignature(commit, files); signatures.set(commit.sha, signature); @@ -1394,8 +1378,8 @@ export const fetchGitLabMergeRequestVersionCommitEvolution = async ({ export const attributeVersionCommitRebaseDrivers = async ({ baseCommits, - transport, projectPath, + transport, unit, unitFiles, }: { @@ -1407,8 +1391,8 @@ export const attributeVersionCommitRebaseDrivers = async ({ subject: string; webUrl: string; }>; - transport: GitLabTransport; projectPath: string; + transport: GitLabTransport; unit: VersionCommitEvolutionUnit; unitFiles: ReadonlyArray; }): Promise> => { @@ -1431,9 +1415,9 @@ export const attributeVersionCommitRebaseDrivers = async ({ batch.map(async (commit) => { try { const files = await fetchGitLabCommitDiff({ - transport, projectPath, sha: commit.sha, + transport, }); signatures.set( commit.sha, @@ -1453,12 +1437,12 @@ export const attributeVersionCommitRebaseDrivers = async ({ }; export const fetchGitLabVersionCommitUnitDiff = async ({ - transport, projectPath: rawProjectPath, + transport, unit, }: { - transport: GitLabTransport; projectPath: string; + transport: GitLabTransport; unit: VersionCommitEvolutionUnit; }): Promise> => { const projectPath = validateProjectPath(rawProjectPath); @@ -1467,7 +1451,7 @@ export const fetchGitLabVersionCommitUnitDiff = async ({ } if (unit.kind === 'added' && unit.after) { return scopeVersionCommitFiles( - await fetchGitLabCommitDiff({ transport, projectPath, sha: unit.after.sha }), + await fetchGitLabCommitDiff({ projectPath, sha: unit.after.sha, transport }), unit.id, ); } @@ -1478,9 +1462,9 @@ export const fetchGitLabVersionCommitUnitDiff = async ({ } const compare = await readRepositoryCompare({ from: unit.before.sha, - transport, projectPath, to: parent, + transport, }); return scopeVersionCommitFiles( asArray(compare.diffs) @@ -1496,8 +1480,8 @@ export const fetchGitLabVersionCommitUnitDiff = async ({ 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 }), + readCommitPatchFiles({ projectPath, sha: unit.before.sha, transport }), + readCommitPatchFiles({ projectPath, sha: unit.after.sha, transport }), ]); const blobs = new Map(); const readBlob = async (filePath: string, ref: string) => { @@ -1549,17 +1533,21 @@ export const fetchGitLabVersionCommitUnitDiff = async ({ }; export const projectMergeRequestVersionRef = ( - version: MergeRequestVersionRef & { number?: number; createdAt?: string; diffStat?: ReviewVersionOption['diffStat']; isHead?: boolean; previousCreatedAt?: string; previousNumber?: number }, + version: MergeRequestVersionRef & { + createdAt?: string; + diffStat?: ReviewVersionOption['diffStat']; + isHead?: boolean; + number?: number; + 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), - ), + revisionRef(version.headSha, versionRevisionLabel(version.label, undefined)), ), ...(version.diffStat ? { diffStat: version.diffStat } : {}), ...(version.isHead != null ? { isHead: version.isHead } : {}), @@ -1579,9 +1567,7 @@ export const projectVersionCompare = ( const analysis: DiffComparisonAnalysis = { summary: compare.summary, ...(compare.baseMovement ? { baseMovement: compare.baseMovement } : {}), - ...(compare.commentAssociations - ? { commentAssociations: compare.commentAssociations } - : {}), + ...(compare.commentAssociations ? { commentAssociations: compare.commentAssociations } : {}), ...(compare.warnings ? { warnings: compare.warnings } : {}), }; return diffComparisonView({ @@ -1603,7 +1589,9 @@ export const projectReviewPlan = ({ versionCompare?: MergeRequestVersionCompare | DiffComparisonView | null; }): ReviewPlan => { const projectedEvolution = - evolution && 'recommendation' in evolution && 'reason' in (evolution as MergeRequestVersionCommitEvolution).recommendation + evolution && + 'recommendation' in evolution && + 'reason' in (evolution as MergeRequestVersionCommitEvolution).recommendation ? projectCommitEvolution(evolution as MergeRequestVersionCommitEvolution) : (evolution as ReviewCommitEvolution | undefined); const view = diff --git a/gitlab/src/review-strategy.ts b/gitlab/src/review-strategy.ts index 5818aa1c..5303fbb5 100644 --- a/gitlab/src/review-strategy.ts +++ b/gitlab/src/review-strategy.ts @@ -1,335 +1,15 @@ -// 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}`; +export { + classifyGitLabCommit, + classifyMergeRequestReviewStrategy, + classifyReviewCommit, + classifyReviewStrategy, + orderCommitsTopologically, + overrideMergeRequestReviewStrategy, + reviewStructureFromStrategy, + versionCompareReviewStructureKey, + type ClassifiedCommit, + type ClassifiedCommitRole, + type GitLabMergeRequestCommitLike, + type MergeRequestReviewStrategy, + type ReviewStrategyCommitLike, +} from '@nkzw/codiff-core'; diff --git a/gitlab/src/transport.ts b/gitlab/src/transport.ts index 02b9341b..fbd22603 100644 --- a/gitlab/src/transport.ts +++ b/gitlab/src/transport.ts @@ -6,10 +6,10 @@ */ export type GitLabTransport = { request(request: { + body?: unknown; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; query?: Readonly>; - body?: unknown; }): Promise; /** * Optional paginated reader. When omitted, {@link request} is called with @@ -31,16 +31,20 @@ export type FakeGitLabTransportRoute = { 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); + 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>) => @@ -56,14 +60,20 @@ const queryKey = (query?: Readonly>) = */ export const createFakeGitLabTransport = ( routes: ReadonlyArray, -): GitLabTransport & { calls: Array<{ method: string; path: string; query?: Record }> } => { +): 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 matchRoute = ( + method: string, + path: string, + query?: Readonly>, + ) => { const key = queryKey(query); return ( routes.find( @@ -89,7 +99,9 @@ export const createFakeGitLabTransport = ( }); const route = matchRoute(method, request.path, request.query); if (!route) { - throw new Error(`No fake GitLab route for ${method} ${request.path}?${queryKey(request.query)}`); + throw new Error( + `No fake GitLab route for ${method} ${request.path}?${queryKey(request.query)}`, + ); } const response = typeof route.response === 'function' @@ -103,7 +115,9 @@ export const createFakeGitLabTransport = ( 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); + const route = + matchRoute('GET', request.path, pageQuery) ?? + (page === 1 ? matchRoute('GET', request.path, request.query) : undefined); if (!route) { break; } diff --git a/package.json b/package.json index 3d746647..4b949477 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", @@ -48,6 +48,7 @@ "test:integration": "vp run build && vp test -c vitest.cloudflare.config.ts" }, "dependencies": { + "codiff": "link:", "electron-squirrel-startup": "^1.0.1", "node-pty": "^1.1.0", "proper-lockfile": "^4.1.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89224ff..6f4f28d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,19 +6,26 @@ 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 - vite: npm:@voidzero-dev/vite-plus-core@^0.2.4 - vitest: 4.1.10 + codiff: 'link:' importers: .: dependencies: + codiff: + specifier: 'link:' + version: 'link:' electron-squirrel-startup: specifier: ^1.0.1 version: 1.0.1 @@ -99,13 +106,13 @@ importers: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@^0.2.4 + specifier: 'catalog:' version: '@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(typescript@7.0.2)' vite-plus: specifier: 'catalog:' version: 0.2.4(@types/node@26.1.1)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(typescript@7.0.2))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.49.0)(tsx@4.23.1)(typescript@7.0.2) vitest: - specifier: 4.1.10 + specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(typescript@7.0.2))(jsdom@29.1.1(@noble/hashes@2.2.0)) core: @@ -147,6 +154,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': @@ -232,7 +245,7 @@ importers: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@^0.2.4 + specifier: 'catalog:' version: '@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(typescript@7.0.2)' vite-plus: specifier: 'catalog:' @@ -490,7 +503,7 @@ packages: peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 - vitest: 4.1.10 + vitest: ^4.1.0 '@cloudflare/workerd-darwin-64@1.20260710.1': resolution: {integrity: sha512-OqJl2eWF5+y9jarMm3YqqCTUe7Hd4ihogX5jyRU8iaAgOVyDr/Bk6aXpPCVUi1/MHzO93a18R/TmSTtzmB0sQw==} @@ -3531,7 +3544,7 @@ packages: react-dom: ^18.0.0 || ^19.0.0 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 - vitest: 4.1.10 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': @@ -3626,6 +3639,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4290,6 +4306,9 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -6496,9 +6515,8 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} yjs@13.6.31: resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} @@ -9906,6 +9924,8 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -10571,7 +10591,7 @@ snapshots: dependencies: debug: 4.4.3 get-stream: 5.2.0 - yauzl: 3.4.0 + yauzl: 2.10.0 optionalDependencies: '@types/yauzl': 2.10.3 transitivePeerDependencies: @@ -10601,6 +10621,10 @@ snapshots: dependencies: format: 0.2.2 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -13169,9 +13193,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@3.4.0: + yauzl@2.10.0: dependencies: - pend: 1.2.0 + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 yjs@13.6.31: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebafdf45..f07dc006 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - service - web - gitlab + - github allowBuilds: '@google/genai': false @@ -31,9 +32,7 @@ onlyBuiltDependencies: - electron-winstaller - unrs-resolver overrides: - extract-zip>yauzl: ^3.4.0 - vite: 'catalog:' - vitest: 'catalog:' + codiff: 'link:' peerDependencyRules: allowAny: - vite 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/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}', 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),