From fdfd318a39662200f566fb632911b31e8930db85 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 20:33:25 +0000 Subject: [PATCH 01/41] feat(web): apply selected review suggestions in one revision Preview exact replacements across files and publish one changeset using an inspected-head update. Preserve line endings, BOM and mode; leave discussions open. Reuse existing APIs without schema changes. Validation: 779 web tests on reconciled main, typecheck, lint, production build and four real-server desktop/phone tests. Earlier mutation pass killed all 34 sites before grouping extraction; behavior tests rerun after extraction. --- CHANGELOG.md | 4 + ROADMAP.md | 2 +- apps/web/e2e/review-suggestions.spec.ts | 109 ++++++++ .../components/proposal/BatchSuggestions.vue | 235 ++++++++++++++++++ .../src/components/proposal/ReviewThreads.vue | 7 + apps/web/src/lib/applySuggestion.spec.ts | 195 ++++++++++++++- apps/web/src/lib/applySuggestion.ts | 91 ++++++- docs/adr/0030-fenced-review-suggestions.md | 9 +- docs/guides/web-ui.md | 6 + 9 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/proposal/BatchSuggestions.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index ac240f42..6bddad6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ on-disk formats are versioned and refuse to open when newer than the build under - Use component-specific fast CI for ordinary commits and cancel superseded work. Run the full platform/backend/browser/security matrix during qualification, reuse one server build for SDK runtimes, and retain compiled workspace caches. - Separate release preparation from publication. Promote the exact prepared and attested archives/images without rebuilding or repeating the Rust suite, with a three-minute publishing job and two-minute parallel public-install checks. Cold preparation and runner queues remain outside the five-minute ready-candidate publication target. +### Added + +- Authors can preview and apply selected review suggestions across files as one proposal revision. Exact source lines, executable mode, BOM and line endings are preserved; conflicting selections are refused and discussions remain open. + ## [0.3.0-beta.7] - 2026-10-29 **Upgrade:** metadata schema 8 and control schema are unchanged from beta.6. Back up before upgrading. Returning to beta.5 or older requires the pre-upgrade backup. diff --git a/ROADMAP.md b/ROADMAP.md index 90765423..293ec379 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,7 +103,7 @@ Work in the following order; keep each deliverable usable across the relevant cl historical instructions and source links. Proposal updates now have an atomic inspected-head precondition as the prerequisite for safe review edits. Workspace mutations now compare the observed base/head in their final transaction, and proposal closing preserves terminal states. The final integration transaction also - rechecks the proposal head and open state. Single-line suggestions support explicit author preview and application. Next add batched/range suggestions and requested reviewers. Optional policy-required line-discussion resolution is now implemented + rechecks the proposal head and open state. Single-line and batched suggestions support explicit author preview and application across files in one revision. Next add range suggestions and requested reviewers. Optional policy-required line-discussion resolution is now implemented across core, API, CLI explanations and Web UI. Preserve drafts, exact-head checks and explicit confirmation when a user changes executable work or a review decision. Account-backed drafts and non-text conflict resolution remain open. The development Home page uses one live stream for a selected repository, retaining complete snapshots and diff --git a/apps/web/e2e/review-suggestions.spec.ts b/apps/web/e2e/review-suggestions.spec.ts index 94441f9f..f4616a17 100644 --- a/apps/web/e2e/review-suggestions.spec.ts +++ b/apps/web/e2e/review-suggestions.spec.ts @@ -102,3 +102,112 @@ test('a reviewer proposes code and the author applies the exact replacement with await author.close() await reviewer.close() }) + +test('the author previews and applies selected suggestions across files in one revision', async ({ + cast, + openAs, +}, info) => { + const { repo } = await freshRepo(cast, 'batch-suggestions') + const alice = cast.as('alice') + const initial = await commitFiles( + alice, + repo, + { + 'script.sh': '\uFEFFfirst\r\nsecond\r\nthird\r\n', + 'note.txt': 'old\n', + }, + 'Files for review', + ) + const proposal = await openProposal(alice, repo, { + head: initial.head, + title: 'Apply reviewed fixes together', + }) + const entries = [ + { path: 'script.sh', line: 1, replacement: 'one\nextra' }, + { path: 'script.sh', line: 3, replacement: 'three' }, + { path: 'note.txt', line: 1, replacement: 'new' }, + ] + const bob = cast.as('bob') + for (const entry of entries) { + await bob.ok_( + bob.post(`/v1/repos/${repo}/proposals/${proposal.id}/threads`, { + expected_head: initial.head, + anchor: { + base: initial.base, + head: initial.head, + side: 'new', + path: entry.path, + line: entry.line, + }, + body: `Use reviewed text.\n\n\`\`\`suggestion\n${entry.replacement}\n\`\`\``, + }), + 'create suggestion', + ) + } + const url = `/repos/${repo}/proposals/${proposal.id}?tab=reviews` + const reviewer = await openAs('bob') + await reviewer.goto(url) + await expect(reviewer.getByText('Use reviewed text.', { exact: true })).toHaveCount(3) + await expect(reviewer.getByText('Apply several suggestions', { exact: true })).toHaveCount(0) + const author = await openAs('alice') + await author.goto(url) + await author.getByText('Apply several suggestions', { exact: true }).click() + for (const entry of entries) + await author + .getByRole('checkbox', { name: `${entry.path}, line ${entry.line}`, exact: true }) + .check() + await author.getByRole('button', { name: 'Review selected suggestions', exact: true }).click() + const dialog = author.getByRole('dialog', { name: 'Apply selected suggestions?' }) + await expect( + dialog.getByRole('button', { name: 'Apply selected suggestions', exact: true }), + ).toBeEnabled() + await expect(dialog).toContainText('3 suggestions in 2 files') + expect( + ( + await alice.ok_( + alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`), + 'inspect original revision', + ) + ).proposal.head, + ).toBe(initial.head) + await author.screenshot({ path: info.outputPath('batch-suggestion-preview.png'), fullPage: true }) + const mutations: string[] = [] + author.on('request', (request) => { + if (request.method() === 'POST' && new URL(request.url()).pathname.endsWith('/changes')) + mutations.push('changes') + if ( + request.method() === 'PATCH' && + new URL(request.url()).pathname.endsWith(`/proposals/${proposal.id}`) + ) + mutations.push('proposal') + }) + await dialog.getByRole('button', { name: 'Apply selected suggestions', exact: true }).click() + await expect(dialog).toHaveCount(0) + expect(mutations).toEqual(['changes', 'proposal']) + const current = await alice.ok_( + alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`), + 'read resulting revision', + ) + expect(current.proposal.head).not.toBe(initial.head) + for (const [path, text] of [ + ['script.sh', '\uFEFFone\r\nextra\r\nsecond\r\nthree\r\n'], + ['note.txt', 'new\n'], + ]) { + const response = await fetch( + `${cast.baseURL}/v1/repos/${repo}/files/${path}?rev=${current.proposal.head}`, + { headers: { authorization: `Bearer ${cast.token('alice')}` } }, + ) + expect(response.status).toBe(200) + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual( + Array.from(new TextEncoder().encode(text)), + ) + } + const threads = await bob.ok_( + bob.get(`/v1/repos/${repo}/proposals/${proposal.id}/threads`), + 'read retained discussions', + ) + expect(threads.threads).toHaveLength(3) + expect(threads.threads.every((thread: { resolved: boolean }) => !thread.resolved)).toBe(true) + await reviewer.close() + await author.close() +}) diff --git a/apps/web/src/components/proposal/BatchSuggestions.vue b/apps/web/src/components/proposal/BatchSuggestions.vue new file mode 100644 index 00000000..d92fede1 --- /dev/null +++ b/apps/web/src/components/proposal/BatchSuggestions.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/apps/web/src/components/proposal/ReviewThreads.vue b/apps/web/src/components/proposal/ReviewThreads.vue index 25852123..d0bc8336 100644 --- a/apps/web/src/components/proposal/ReviewThreads.vue +++ b/apps/web/src/components/proposal/ReviewThreads.vue @@ -8,6 +8,7 @@ import { usePagedResource } from '@/composables/usePagedResource' import { useLiveReload } from '@/composables/useLiveReload' import ThreadComposer from './ThreadComposer.vue' import ReviewThreadCard from './ReviewThreadCard.vue' +import BatchSuggestions from './BatchSuggestions.vue' import Button from '@/ui/Button.vue' import ErrorState from '@/ui/ErrorState.vue' const props = defineProps<{ repo: string; proposal: Proposal; quote?: LineQuote | null }>() @@ -89,6 +90,12 @@ watch([() => props.repo, () => props.proposal.id, () => auth.token], () => { > No {{ filter }} discussions in the loaded results.

+ { ) }) }) + +describe('batched suggestion publication', () => { + it('combines same-file suggestions against their original line numbers', async () => { + const { api, raw } = fixture() + raw.tree.file.mockResolvedValue({ + bytes: new TextEncoder().encode('\uFEFFold\r\ntwo\r\n'), + truncated: false, + }) + const previews = await previewSuggestions( + api, + 'team/repo', + 'seen', + [ + { anchor, replacement: 'new' }, + { anchor: { ...anchor, line: 2, text: 'two' }, replacement: 'more\nlines' }, + ], + context, + ) + expect(previews).toHaveLength(1) + expect(previews[0]!.after).toBe('\uFEFFnew\r\nmore\r\nlines\r\n') + expect(previews[0]!.change.executable).toBe(true) + expect(raw.tree.file).toHaveBeenCalledOnce() + }) + + it('publishes multiple files as one workspace change and one head update', async () => { + const { api, raw } = fixture() + const previews = await previewSuggestions( + api, + 'team/repo', + 'seen', + [ + { anchor, replacement: 'new' }, + { anchor: { ...anchor, path: 'another.sh' }, replacement: 'another' }, + ], + context, + ) + await applySuggestions(api, 'team/repo', 'proposal', 'dev', 'seen', previews, context) + expect(raw.workspaces.modify).toHaveBeenCalledOnce() + const body = raw.workspaces.modify.mock.calls[0]![2] + expect(body.expected_head).toBe('seen') + expect(body.changes.map((change: { path: string }) => change.path)).toEqual([ + 'another.sh', + 'script.sh', + ]) + expect(raw.proposals.update).toHaveBeenCalledOnce() + expect(raw.workspaces.remove).toHaveBeenCalledOnce() + }) + + it('rejects competing replacements for one original line before reading files', async () => { + const { api, raw } = fixture() + await expect( + previewSuggestions( + api, + 'team/repo', + 'seen', + [ + { anchor, replacement: 'one' }, + { anchor, replacement: 'two' }, + ], + context, + ), + ).rejects.toThrow(/same line/) + expect(raw.tree.file).not.toHaveBeenCalled() + }) + + it('rejects mixed revisions and empty batches before reading files', async () => { + const { api, raw } = fixture() + await expect( + previewSuggestions( + api, + 'team/repo', + 'seen', + [{ anchor: { ...anchor, head: 'stale' }, replacement: 'new' }], + context, + ), + ).rejects.toThrow(/revision/) + await expect(previewSuggestions(api, 'team/repo', 'seen', [], context)).rejects.toThrow( + /Select/, + ) + expect(raw.tree.file).not.toHaveBeenCalled() + }) +}) + +describe('suggestion limits and ordering', () => { + it('applies lower original lines after higher lines even when they insert new lines', async () => { + const { api, raw } = fixture() + raw.tree.file.mockResolvedValue({ + bytes: new TextEncoder().encode('first\nsecond\n'), + truncated: false, + }) + const [preview] = await previewSuggestions( + api, + 'team/repo', + 'seen', + [ + { anchor: { ...anchor, text: 'first' }, replacement: 'one\nextra' }, + { anchor: { ...anchor, line: 2, text: 'second' }, replacement: 'two' }, + ], + context, + ) + expect(preview!.after).toBe('one\nextra\ntwo\n') + }) + + it('accepts exactly 100 suggestions and rejects 101 before reading files', async () => { + const { api, raw } = fixture() + const suggestions = Array.from({ length: 100 }, (_, index) => ({ + anchor: { ...anchor, path: `f${index}` }, + replacement: 'new', + })) + expect(await previewSuggestions(api, 'team/repo', 'seen', suggestions, context)).toHaveLength( + 100, + ) + raw.tree.file.mockClear() + await expect( + previewSuggestions(api, 'team/repo', 'seen', [...suggestions, suggestions[0]!], context), + ).rejects.toThrow(/100/) + expect(raw.tree.file).not.toHaveBeenCalled() + }) + + it('accepts a 4 MiB result and rejects one additional byte', async () => { + const { api, raw } = fixture() + raw.tree.file.mockResolvedValue({ bytes: new TextEncoder().encode('old'), truncated: false }) + const replacement = 'x'.repeat(4 * 1024 * 1024) + expect( + await previewSuggestions( + api, + 'team/repo', + 'seen', + [{ anchor: { ...anchor, text: 'old' }, replacement }], + context, + ), + ).toHaveLength(1) + await expect( + previewSuggestions( + api, + 'team/repo', + 'seen', + [{ anchor: { ...anchor, text: 'old' }, replacement: replacement + 'x' }], + context, + ), + ).rejects.toThrow(/4 MiB/) + }) + + it('enforces file fetch bounds, malformed UTF-8 and missing entries', async () => { + const { api, raw } = fixture() + await previewSuggestion(api, 'team/repo', anchor, 'new', context) + expect(raw.tree.file).toHaveBeenCalledWith( + 'team/repo', + 'seen', + 'script.sh', + 1024 * 1024, + context, + ) + raw.tree.file.mockResolvedValue({ + bytes: new Uint8Array([111, 108, 100, 10, 255]), + truncated: false, + }) + await expect( + previewSuggestion(api, 'team/repo', { ...anchor, text: 'old' }, 'new', context), + ).rejects.toThrow() + raw.tree.resolve.mockResolvedValue({}) + await expect(previewSuggestion(api, 'team/repo', anchor, 'new', context)).rejects.toThrow( + /regular text files/, + ) + }) + + it('bounds prepared files and records a useful change message', async () => { + const { api, raw } = fixture() + const preview = await previewSuggestion(api, 'team/repo', anchor, 'new', context) + for (const count of [0, 101]) { + await expect( + applySuggestions(api, 'team/repo', 'p', 'dev', 'seen', Array(count).fill(preview), context), + ).rejects.toThrow(/100/) + } + expect(raw.workspaces.create).not.toHaveBeenCalled() + await applySuggestions(api, 'team/repo', 'p', 'dev', 'seen', Array(100).fill(preview), context) + expect(raw.workspaces.modify.mock.calls[0]![2].message).toBe('Apply review suggestions') + await applySuggestions(api, 'team/repo', 'p', 'dev', 'seen', [preview], context) + expect(raw.workspaces.modify.mock.calls[1]![2].message).toBe( + 'Apply review suggestion to script.sh', + ) + expect(raw.workspaces.remove).toHaveBeenCalledWith( + 'team/repo', + expect.any(String), + expect.objectContaining({ quietUnauthenticated: true }), + ) + }) +}) diff --git a/apps/web/src/lib/applySuggestion.ts b/apps/web/src/lib/applySuggestion.ts index 4cfc97bd..871362d8 100644 --- a/apps/web/src/lib/applySuggestion.ts +++ b/apps/web/src/lib/applySuggestion.ts @@ -34,6 +34,76 @@ export async function previewSuggestion( return { before, after, change: putFile(anchor.path, after, location.entry.executable) } } +export interface SuggestionInput { + anchor: ReviewLineAnchor + replacement: string +} + +/** Prepare one change per file, applying original line numbers from bottom to top. */ +export async function previewSuggestions( + api: Api, + repo: string, + head: string, + suggestions: SuggestionInput[], + request: Session, +): Promise { + const files = groupSuggestions(head, suggestions) + const result: SuggestionPreview[] = [] + let bytes = 0 + for (const path of [...files.keys()].sort()) { + request.signal?.throwIfAborted() + const preview = await previewFileSuggestions(api, repo, files.get(path)!, request) + bytes += new TextEncoder().encode(preview.after).length + if (bytes > 4 * 1024 * 1024) + throw new Error('The selected changes exceed 4 MiB. Select fewer suggestions.') + result.push(preview) + } + return result +} + +function groupSuggestions( + head: string, + suggestions: SuggestionInput[], +): Map { + if (!suggestions.length || suggestions.length > 100) + throw new Error('Select between 1 and 100 suggestions.') + const seen = new Set() + const files = new Map() + for (const suggestion of suggestions) { + const anchor = suggestion.anchor + if (anchor.head !== head || anchor.side !== 'new') + throw new Error('Every suggestion must belong to the current proposed revision.') + const key = JSON.stringify([anchor.path, anchor.line]) + if (seen.has(key)) throw new Error('Choose only one suggestion for the same line.') + seen.add(key) + const group = files.get(anchor.path) || [] + group.push(suggestion) + files.set(anchor.path, group) + } + return files +} + +/** Each anchor still addresses the immutable original file's line numbers. */ +async function previewFileSuggestions( + api: Api, + repo: string, + suggestions: SuggestionInput[], + request: Session, +): Promise { + const group = [...suggestions].sort((a, b) => b.anchor.line - a.anchor.line) + const first = group[0]! + const preview = await previewSuggestion(api, repo, first.anchor, first.replacement, request) + for (const suggestion of group.slice(1)) + preview.after = replaceSuggestedLine( + preview.after, + suggestion.anchor.line, + suggestion.anchor.text, + suggestion.replacement, + ) + preview.change = putFile(first.anchor.path, preview.after, preview.change.executable) + return preview +} + /** A private workspace prepares content; only a head-checked update publishes it to the proposal. */ export async function applySuggestion( api: Api, @@ -44,6 +114,20 @@ export async function applySuggestion( preview: SuggestionPreview, request: Session, ): Promise { + return applySuggestions(api, repo, proposal, stream, head, [preview], request) +} + +/** Publish the complete preview in one changeset with one inspected-head update. */ +export async function applySuggestions( + api: Api, + repo: string, + proposal: string, + stream: string, + head: string, + previews: SuggestionPreview[], + request: Session, +): Promise { + if (!previews.length || previews.length > 100) throw new Error('Select between 1 and 100 files.') request.signal?.throwIfAborted() const name = workspaceName() const workspace = await api.workspaces.create(repo, { name, stream, from_rev: head }, request) @@ -55,9 +139,12 @@ export async function applySuggestion( repo, name, { - changes: [preview.change], + changes: previews.map((preview) => preview.change), expected_head: head, - message: `Apply review suggestion to ${preview.change.path}`, + message: + previews.length === 1 + ? `Apply review suggestion to ${previews[0]!.change.path}` + : 'Apply review suggestions', }, request, ) diff --git a/docs/adr/0030-fenced-review-suggestions.md b/docs/adr/0030-fenced-review-suggestions.md index 4180c54a..a640241f 100644 --- a/docs/adr/0030-fenced-review-suggestions.md +++ b/docs/adr/0030-fenced-review-suggestions.md @@ -30,8 +30,15 @@ The operation does not integrate a branch, forge an approval, preserve a validat content or automatically resolve the discussion. Normal new-head review and exact-tree check rules still apply. The old suggestion remains attached to its old revision and readable. +Authors can also select suggestions from loaded discussions for one explicit batch preview. +All anchors must belong to the current proposed head; duplicate file/line anchors are refused. +Within each file, replacements run from the last original line upward so inserted or deleted +lines cannot shift remaining anchors. Up to 100 suggestions and 4 MiB of resulting content +produce one workspace changeset and one head-checked proposal update. Files are read once +and every exact source line is checked before publishing. Discussions remain open. + ## Remaining scope -Multi-line anchor ranges, batched application, server-native suggestion metadata and an +Multi-line anchor ranges, server-native suggestion metadata and an explicit native CLI apply command remain future work. Existing CLI/SDK thread body operations can read and write the convention without schema changes. diff --git a/docs/guides/web-ui.md b/docs/guides/web-ui.md index 4452b19a..291fbd44 100644 --- a/docs/guides/web-ui.md +++ b/docs/guides/web-ui.md @@ -607,6 +607,12 @@ and line endings. Existing checks and approvals must match the new revision. The stays open until reviewed and resolved separately. Earlier-revision suggestions remain readable and copyable; they cannot overwrite newer work. +To combine changes, expand **Apply several suggestions** in the discussions, select the +suggestions and choose **Review selected suggestions**. Inspect the prepared files before +confirming. Up to 100 suggestions across files become one revision; conflicting suggestions +for the same line are refused. Only loaded discussions are included: use **Load more +discussions** to include older results. The total prepared content is limited to 4 MiB. + Application requires a server with `proposals.update_head` and is limited to regular UTF-8 files up to 1 MiB. Symlinks, binary files, old-side lines and changed source lines require the ordinary editor or CLI. This first version replaces one anchored line, not a selected range. From 6c56df367c12d6950b2c4de184c2dd23cb81d62b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 20:40:49 +0000 Subject: [PATCH 02/41] feat(web): apply revision-bound suggestions to line ranges Use an explicit range fence in existing discussion bodies, preserve source format, retain range drafts and reject overlapping batches. Show the original file and require author confirmation. No protocol or storage change. Validation: 785 full web tests before boundary additions, 24 focused final tests, typecheck, six real-server desktop/phone scenarios. Mutation pass killed 66 of 67 candidates; remaining single-line parser default mutation produces the same public result. --- CHANGELOG.md | 2 + ROADMAP.md | 2 +- apps/web/e2e/review-suggestions.spec.ts | 65 +++++++++++++++++++ .../components/proposal/BatchSuggestions.vue | 3 +- .../components/proposal/ReviewSuggestion.vue | 30 +++++++-- .../components/proposal/ThreadComposer.vue | 52 +++++++++++++-- apps/web/src/lib/applySuggestion.spec.ts | 30 +++++++++ apps/web/src/lib/applySuggestion.ts | 28 ++++++-- apps/web/src/lib/rangeSuggestions.spec.ts | 52 +++++++++++++++ apps/web/src/lib/reviewSuggestion.ts | 41 ++++++++++-- docs/adr/0030-fenced-review-suggestions.md | 12 ++-- docs/guides/web-ui.md | 12 ++-- 12 files changed, 295 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/lib/rangeSuggestions.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bddad6f..b92b0a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ on-disk formats are versioned and refuse to open when newer than the build under ### Added +- Review suggestions can replace up to 100 consecutive original lines. Range drafts survive reload, previews show the source file, and batches reject overlapping ranges before publication. Existing one-line suggestions keep their original format. + - Authors can preview and apply selected review suggestions across files as one proposal revision. Exact source lines, executable mode, BOM and line endings are preserved; conflicting selections are refused and discussions remain open. ## [0.3.0-beta.7] - 2026-10-29 diff --git a/ROADMAP.md b/ROADMAP.md index 293ec379..38597056 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,7 +103,7 @@ Work in the following order; keep each deliverable usable across the relevant cl historical instructions and source links. Proposal updates now have an atomic inspected-head precondition as the prerequisite for safe review edits. Workspace mutations now compare the observed base/head in their final transaction, and proposal closing preserves terminal states. The final integration transaction also - rechecks the proposal head and open state. Single-line and batched suggestions support explicit author preview and application across files in one revision. Next add range suggestions and requested reviewers. Optional policy-required line-discussion resolution is now implemented + rechecks the proposal head and open state. Single-line, range and batched suggestions support explicit author preview and application across files in one revision. Next add requested reviewers. Optional policy-required line-discussion resolution is now implemented across core, API, CLI explanations and Web UI. Preserve drafts, exact-head checks and explicit confirmation when a user changes executable work or a review decision. Account-backed drafts and non-text conflict resolution remain open. The development Home page uses one live stream for a selected repository, retaining complete snapshots and diff --git a/apps/web/e2e/review-suggestions.spec.ts b/apps/web/e2e/review-suggestions.spec.ts index f4616a17..79044c85 100644 --- a/apps/web/e2e/review-suggestions.spec.ts +++ b/apps/web/e2e/review-suggestions.spec.ts @@ -211,3 +211,68 @@ test('the author previews and applies selected suggestions across files in one r await reviewer.close() await author.close() }) + +test('a reviewer selects a range and the author confirms the exact prepared file', async ({ + cast, + openAs, +}, info) => { + const { repo } = await freshRepo(cast, 'range-suggestions') + const alice = cast.as('alice') + const original = '\uFEFFfirst\r\nold one\r\nold two\r\nlast\r\n' + const initial = await commitFiles(alice, repo, { 'code.txt': original }, 'Range source') + const proposal = await openProposal(alice, repo, { head: initial.head, title: 'Review a block' }) + const url = `/repos/${repo}/proposals/${proposal.id}` + const reviewer = await openAs('bob') + await reviewer.goto(url) + await reviewer + .getByRole('button', { name: 'Discuss new line 2 of code.txt', exact: true }) + .click() + await reviewer.getByRole('button', { name: 'Suggest a replacement', exact: true }).click() + await reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }).fill('101') + await expect( + reviewer.getByRole('button', { name: 'Start discussion', exact: true }), + ).toBeDisabled() + await reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }).fill('2') + await reviewer + .getByRole('textbox', { name: 'Replacement code', exact: true }) + .fill('new one\nnew two\nextra') + await reviewer.reload() + await expect( + reviewer.getByRole('spinbutton', { name: 'Lines to replace', exact: true }), + ).toHaveValue('2') + await expect( + reviewer.getByRole('textbox', { name: 'Replacement code', exact: true }), + ).toHaveValue('new one\nnew two\nextra') + await reviewer.getByRole('button', { name: 'Start discussion', exact: true }).click() + await expect(reviewer.getByRole('region', { name: 'Suggested replacement' })).toBeVisible() + const author = await openAs('alice') + await author.goto(`${url}?tab=reviews`) + await author.getByRole('button', { name: 'Review and apply suggestion', exact: true }).click() + const dialog = author.getByRole('dialog', { name: 'Apply this suggestion?' }) + await expect(dialog).toContainText('lines 2–3') + await expect(dialog).toContainText('old two') + await expect(dialog).toContainText('new two') + await author.screenshot({ path: info.outputPath('range-suggestion-preview.png'), fullPage: true }) + const before = await alice.ok_( + alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`), + 'check before range confirmation', + ) + expect(before.proposal.head).toBe(initial.head) + await dialog.getByRole('button', { name: 'Apply suggestion', exact: true }).click() + await expect(dialog).toHaveCount(0) + const after = await alice.ok_( + alice.get(`/v1/repos/${repo}/proposals/${proposal.id}`), + 'read range revision', + ) + expect(after.proposal.head).not.toBe(initial.head) + const response = await fetch( + `${alice.base}/v1/repos/${repo}/files/code.txt?rev=${after.proposal.head}`, + { headers: { Authorization: `Bearer ${alice.token}` } }, + ) + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual( + Buffer.from('\uFEFFfirst\r\nnew one\r\nnew two\r\nextra\r\nlast\r\n'), + ) + await author.close() + await reviewer.close() +}) diff --git a/apps/web/src/components/proposal/BatchSuggestions.vue b/apps/web/src/components/proposal/BatchSuggestions.vue index d92fede1..522c296c 100644 --- a/apps/web/src/components/proposal/BatchSuggestions.vue +++ b/apps/web/src/components/proposal/BatchSuggestions.vue @@ -40,6 +40,7 @@ const candidates = computed(() => version: thread.version, anchor: thread.anchor, replacement: suggestion.replacement, + lines: suggestion.lines, }, ] }), @@ -147,7 +148,7 @@ async function apply() {
  • diff --git a/apps/web/src/components/proposal/ReviewSuggestion.vue b/apps/web/src/components/proposal/ReviewSuggestion.vue index 01e20d9e..33919522 100644 --- a/apps/web/src/components/proposal/ReviewSuggestion.vue +++ b/apps/web/src/components/proposal/ReviewSuggestion.vue @@ -56,6 +56,8 @@ watch( () => props.proposal.id, () => props.proposal.head, () => props.thread.id, + () => props.thread.version, + () => props.thread.body, () => auth.token, ], reset, @@ -76,6 +78,7 @@ async function inspect() { props.thread.anchor, props.suggestion.replacement, { token: auth.token, quietUnauthenticated: true, signal: controller.signal }, + props.suggestion.lines, ) if (mine === generation) preview.value = prepared } catch (cause) { @@ -115,8 +118,13 @@ async function apply() {