Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- the composer suite covers the draft clear rules and the provider-arm anchored post in one cohesive file */
// Clear-rule coverage for the comment composer's durable draft. The composer
// clears its draft on three committed outcomes — comment post, add-to-review,
// and a confirmed discard — and keeps it on a dismissed-without-confirmation
Expand Down Expand Up @@ -154,18 +155,6 @@ vi.mock('@/lib/pr-review/pending-review-provider', () => ({
}));

vi.mock('@/lib/pr-review/use-pr-review-mutations', () => ({
formatPendingCommentBody: (item: {
path: string;
line: number;
startLine?: number;
body: string;
}) => {
const location =
item.startLine !== undefined && item.startLine !== item.line
? `${item.path}:L${item.startLine}–L${item.line}`
: `${item.path}:L${item.line}`;
return `${location}\n\n${item.body}`;
},
useCreateReviewCommentMutation: () => ({
mutateAsync: createCommentMocks.mutateAsync,
isPending: createCommentMocks.isPending,
Expand Down Expand Up @@ -269,6 +258,18 @@ describe('PrReviewCommentComposer draft clear rules', () => {
footerProp(element, 'onCommentNow')?.();
await flushMicrotasks();

// The GitHub arm keeps its exact pre-s6 variables: the position rides
// the flat input fields, never the provider `anchor` shape (c3).
expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({
owner: 'octocat',
repo: 'hello',
number: 1,
body: 'hello',
path: 'src/a.ts',
line: 10,
side: 'RIGHT',
commitSha: 'a'.repeat(40),
});
expect(clearDraft).toHaveBeenCalledWith('u1', 'pr-comment:key');
});

Expand Down Expand Up @@ -303,7 +304,7 @@ describe('PrReviewCommentComposer draft clear rules', () => {

describe('PrReviewCommentComposer provider arm (s6)', () => {
// A GitLab MR with the same owner/repo/number triple as the GitHub
// fixtures: the folded draft key and the body-only post must differ from
// fixtures: the folded draft key and the anchored post must differ from
// the GitHub arm in both bytes.
const gitlabRef = { platform: 'gitlab' as const, projectPath: 'octocat/hello', mrIid: 1 };
const providerProps = { ...baseProps, prRef: gitlabRef };
Expand All @@ -320,15 +321,33 @@ describe('PrReviewCommentComposer provider arm (s6)', () => {
vi.clearAllMocks();
});

it('posts body-only through the provider arm, with the location anchored in the text', async () => {
it('posts the tapped diff position as the real anchor through the provider arm (c3)', async () => {
createCommentMocks.mutateAsync.mockResolvedValueOnce({});
const element = mountProviderComposer();
typeBody(element, 'hello');
footerProp(element, 'onCommentNow')?.();
await flushMicrotasks();

expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({
body: 'src/a.ts:L10\n\nhello',
body: 'hello',
anchor: { path: 'src/a.ts', side: 'RIGHT', line: 10 },
});
});

it('carries a multi-line range into the anchor (c3)', async () => {
createCommentMocks.mutateAsync.mockResolvedValueOnce({});
// eslint-disable-next-line new-cap
const element = PrReviewCommentComposer({
...providerProps,
startLine: 8,
});
typeBody(element, 'hello');
footerProp(element, 'onCommentNow')?.();
await flushMicrotasks();

expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({
body: 'hello',
anchor: { path: 'src/a.ts', side: 'RIGHT', line: 10, startLine: 8 },
});
});

Expand Down
28 changes: 17 additions & 11 deletions apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ import { useFencedDraftLoad } from '@/lib/persist/use-draft-load';
import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence';
import { getDiffSelection } from '@/lib/pr-review/diff-selection-bridge';
import { usePendingReview } from '@/lib/pr-review/pending-review-provider';
import {
formatPendingCommentBody,
useCreateReviewCommentMutation,
} from '@/lib/pr-review/use-pr-review-mutations';
import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations';
import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref';

type CommentComposerMode =
Expand All @@ -51,10 +48,10 @@ type PrReviewCommentComposerProps = Readonly<{
number: number;
/**
* The provider ref (s6). Present on the GitLab/Bitbucket surface: the
* comment posts through `providerReview.addComment` with the location
* anchored in the body, and the durable comment draft key folds the ref
* identity so a same-numbered GitHub PR never shares this sheet's draft.
* Absent on GitHub, which keeps the exact pre-s6 write path.
* comment posts through `providerReview.addComment` with the tapped diff
* position as a real anchor (c3), and the durable comment draft key folds
* the ref identity so a same-numbered GitHub PR never shares this sheet's
* draft. Absent on GitHub, which keeps the exact pre-s6 write path.
*/
prRef?: ProviderPrRef;
mode: CommentComposerMode;
Expand Down Expand Up @@ -217,11 +214,20 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) {
return;
}
try {
// Provider arms post body-only: the inline position rides in the
// text, because the provider APIs have no comment position.
// Provider arms post the real diff position (c3): the sheet's
// path/side/line selection rides the `anchor` the provider router
// turns into an inline discussion / inline comment.
await createComment.mutateAsync(
prRef
? { body: formatPendingCommentBody({ path, line, startLine, body }) }
? {
body,
anchor: {
path,
side,
line,
...(startLine !== undefined ? { startLine } : {}),
},
}
: {
owner,
repo,
Expand Down
124 changes: 124 additions & 0 deletions apps/mobile/src/components/pr-review/pr-review-submit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ vi.mock('@/lib/pr-review/use-pr-review-mutations', () => ({
isPending: false,
error: submitMutationMock.error,
}),
// Mirrors the real mapper (pure field routing, tested in
// use-pr-review-mutations.test.ts): anchored items ride the `comments`
// batch, an item without a side keeps the text-anchored body fallback.
buildProviderSubmitInput: (
summary: string,
items: readonly {
path: string;
side?: 'LEFT' | 'RIGHT';
line: number;
startLine?: number;
body: string;
}[]
) => {
const comments: {
path: string;
side: 'LEFT' | 'RIGHT';
line: number;
startLine?: number;
body: string;
}[] = [];
const folded: string[] = [];
for (const item of items) {
if (item.side === undefined) {
folded.push(`${item.path}:L${item.line}\n\n${item.body}`);
} else {
const { path, side, line, startLine, body } = item;
comments.push({ path, side, line, startLine, body });
}
}
return {
body: [summary.trim(), ...folded].filter(part => part.length > 0).join('\n\n'),
comments,
};
},
}));

const footerPreferenceMock = vi.hoisted(() => ({
Expand Down Expand Up @@ -321,6 +355,96 @@ describe('PrReviewSubmit footer preference', () => {
});
});

// ── c3: provider arm submits anchored comments ───────────────────────

describe('PrReviewSubmit provider arm (c3)', () => {
const ITEM_FRESH_RANGE: PendingReviewItem = {
id: 'fresh-r',
path: 'src/r.ts',
side: 'LEFT',
line: 8,
startLine: 5,
body: 'R',
commitSha: 'head-1',
};

it('submits fresh pending items as a real anchored comments batch', async () => {
const renderer = mount(GITLAB_REF);

act(() => {
addCommentFn?.(ITEM_FRESH_A);
addCommentFn?.(ITEM_FRESH_B);
addCommentFn?.(ITEM_STALE);
});
act(() => {
submitOnPress(renderer)();
});
await flush();

// The tapped positions ride the `comments` batch (event + items, no
// summary typed); stale items are never sent and stay queued.
expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({
event: 'comment',
comments: [
{ path: 'src/a.ts', side: 'RIGHT', line: 1, body: 'A' },
{ path: 'src/b.ts', side: 'RIGHT', line: 2, body: 'B' },
],
});
expect(latestItems.map(item => item.id)).toEqual(['stale-c']);
});

it('carries a multi-line range into the comment item', async () => {
const renderer = mount(GITLAB_REF);

act(() => {
addCommentFn?.(ITEM_FRESH_RANGE);
});
act(() => {
submitOnPress(renderer, 'Submit review')();
});
await flush();

expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({
event: 'comment',
comments: [{ path: 'src/r.ts', side: 'LEFT', line: 8, startLine: 5, body: 'R' }],
});
});

it('posts the summary alone when nothing is queued (empty state, no comments key)', async () => {
footerPreferenceMock.prReviewFooter = true;
const renderer = mount(GITLAB_REF);

act(() => {
submitOnPress(renderer, 'Submit review')();
});
await flush();

expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({
event: 'comment',
body: '---\nReviewed via the [Kilo iOS app](https://apps.apple.com/app/id6761193135)',
});
});

it('keeps the summary as the review body beside the anchored batch', async () => {
footerPreferenceMock.prReviewFooter = true;
const renderer = mount(GITLAB_REF);

act(() => {
addCommentFn?.(ITEM_FRESH_A);
});
act(() => {
submitOnPress(renderer, 'Submit review')();
});
await flush();

expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({
event: 'comment',
body: '---\nReviewed via the [Kilo iOS app](https://apps.apple.com/app/id6761193135)',
comments: [{ path: 'src/a.ts', side: 'RIGHT', line: 1, body: 'A' }],
});
});
});

// ── s6f: refused-submit wording ──────────────────────────────────────

/** Every string the mounted tree rendered inside a Text element. */
Expand Down
19 changes: 12 additions & 7 deletions apps/mobile/src/components/pr-review/pr-review-submit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { formatNumber } from '@/lib/format';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { mutationErrorDisplay } from '@/lib/pr-review/mutation-error-display';
import {
buildProviderSubmitBody,
buildProviderSubmitInput,
type ProviderReviewEventOption,
useSubmitReviewMutation,
} from '@/lib/pr-review/use-pr-review-mutations';
Expand Down Expand Up @@ -159,9 +159,10 @@ type PrReviewSubmitProps = Readonly<{
eyebrow: string;
/**
* The provider ref (s6). Present on the GitLab/Bitbucket surface: the
* submit posts one `providerReview.submitReview` event with the pending
* comments folded into the body, and the pending-comment edit route is
* the ref's own. Absent on GitHub, which keeps the exact pre-s6 path.
* submit posts one `providerReview.submitReview` event whose fresh pending
* comments ride the real inline `comments` batch (c3), and the
* pending-comment edit route is the ref's own. Absent on GitHub, which
* keeps the exact pre-s6 path.
*/
prRef?: ProviderPrRef;
/**
Expand Down Expand Up @@ -291,13 +292,17 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) {
}
try {
const body = bodyRef.current.trim();
// Provider arms post one event; the summary and every fresh pending
// comment fold into the body (the provider event has no batch).
// Provider arms post one event with a real inline batch (c3): every
// fresh pending comment rides `comments` anchored at its tapped diff
// position, and the summary stays the review body. An item without an
// anchor keeps the text-anchored body fallback.
const provider = buildProviderSubmitInput(body, fresh);
await submitReview.mutateAsync(
prRef
? {
event: EVENT_TO_PROVIDER[event],
body: buildProviderSubmitBody(body, fresh),
...(provider.body.length > 0 ? { body: provider.body } : {}),
...(provider.comments.length > 0 ? { comments: provider.comments } : {}),
}
: buildSubmitReviewInput({
owner,
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@
"banner": {
"title": "Not available on this provider",
"accessibility": "Not available on this provider: {{reason}}"
}
},
"autoMergeUnavailable": "Auto-merge is not available",
"autoMergeUnavailableReason": "Bitbucket Cloud does not expose auto-merge in its API"
},
"merge": {
"merge": "Merge",
Expand Down
Loading
Loading