From f2d69261903a1fa26057bdd046c5f3caf47aab34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 03:07:08 +0200
Subject: [PATCH 01/19] fix(mobile): throw typed errors on Code Reviewer action
failures
---
.../src/lib/hooks/use-code-reviews.test.ts | 330 ++++++++++++++++++
apps/mobile/src/lib/hooks/use-code-reviews.ts | 63 ++--
2 files changed, 372 insertions(+), 21 deletions(-)
create mode 100644 apps/mobile/src/lib/hooks/use-code-reviews.test.ts
diff --git a/apps/mobile/src/lib/hooks/use-code-reviews.test.ts b/apps/mobile/src/lib/hooks/use-code-reviews.test.ts
new file mode 100644
index 0000000000..dad48cc26c
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-code-reviews.test.ts
@@ -0,0 +1,330 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useCancelReview, useCreateManualReview, useRetriggerReview } from './use-code-reviews';
+
+type MutationOptions = {
+ mutationFn?: (vars: unknown) => Promise;
+ onSuccess?: (data: unknown, vars: unknown) => void;
+ onError?: (error: unknown) => void;
+};
+
+const cancelMutateMock = vi.fn();
+const retriggerMutateMock = vi.fn();
+const personalCreateMutateMock = vi.fn();
+const orgCreateMutateMock = vi.fn();
+const invalidateQueriesMock = vi.fn();
+const cancelQueriesMock = vi.fn();
+const getQueryDataMock = vi.fn();
+const setQueryDataMock = vi.fn();
+const toastErrorMock = vi.fn();
+
+// Each test calls exactly one of the three hooks. Capture the most recent
+// useMutation options — that's the hook under test for that test.
+let lastCapturedOptions: MutationOptions | null = null;
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: (opts: MutationOptions) => {
+ lastCapturedOptions = opts;
+ return { mutate: vi.fn() };
+ },
+ useQuery: () => ({ data: undefined }),
+ useQueryClient: () => ({
+ cancelQueries: cancelQueriesMock,
+ getQueryData: getQueryDataMock,
+ setQueryData: setQueryDataMock,
+ invalidateQueries: invalidateQueriesMock,
+ }),
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ codeReviews: {
+ listForUser: { queryKey: () => ['codeReviews', 'listForUser'] },
+ listForOrganization: { queryKey: () => ['codeReviews', 'listForOrganization'] },
+ get: { queryKey: () => ['codeReviews', 'get'] },
+ },
+ }),
+ trpcClient: {
+ codeReviews: {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ cancel: { mutate: (vars: unknown) => cancelMutateMock(vars) },
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ retrigger: { mutate: (vars: unknown) => retriggerMutateMock(vars) },
+ },
+ personalReviewAgent: {
+ createManualReviewJob: {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutate: (vars: unknown) => personalCreateMutateMock(vars),
+ },
+ },
+ organizations: {
+ reviewAgent: {
+ createManualReviewJob: {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutate: (vars: unknown) => orgCreateMutateMock(vars),
+ },
+ },
+ },
+ },
+}));
+
+vi.mock('sonner-native', () => ({
+ toast: { error: (msg: string) => toastErrorMock(msg) },
+}));
+
+// hasInFlightReview / isInFlightReviewStatus are only referenced by the
+// query hooks' refetchInterval callbacks (useReviewList/useReviewDetail),
+// which these tests don't exercise — stub them so the module evaluates.
+vi.mock('@kilocode/app-shared/code-review', () => ({
+ hasInFlightReview: () => false,
+ isInFlightReviewStatus: () => false,
+}));
+
+// PERSONAL_SCOPE is re-exported from use-code-reviewer; stub the import
+// path the production code uses (the re-export in this file does that for
+// callers, but use-code-reviews imports the constant directly). We replace
+// the whole module with a stub whose only export is the literal 'personal'
+// so isPersonal() inside the hook returns the right value.
+vi.mock('@/lib/hooks/use-code-reviewer', () => ({
+ PERSONAL_SCOPE: 'personal',
+}));
+
+function getOptions(hook: 'cancel' | 'retrigger' | 'create', scope = 'personal'): MutationOptions {
+ // Each hook only calls useMutation once; capturing the last one is enough
+ // because every test invokes exactly one hook. Use 'personal' for create
+ // by default so the personal-scoped tRPC mock path is exercised unless a
+ // test explicitly requests the org path.
+ lastCapturedOptions = null;
+ if (hook === 'cancel') {
+ useCancelReview(scope);
+ } else if (hook === 'retrigger') {
+ useRetriggerReview(scope);
+ } else {
+ useCreateManualReview(scope);
+ }
+ if (!lastCapturedOptions) {
+ throw new Error(`mutation options for ${hook} were not captured`);
+ }
+ return lastCapturedOptions;
+}
+
+beforeEach(() => {
+ lastCapturedOptions = null;
+ cancelMutateMock.mockReset();
+ retriggerMutateMock.mockReset();
+ personalCreateMutateMock.mockReset();
+ orgCreateMutateMock.mockReset();
+ invalidateQueriesMock.mockReset();
+ cancelQueriesMock.mockReset();
+ getQueryDataMock.mockReset();
+ setQueryDataMock.mockReset();
+ toastErrorMock.mockReset();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('useCancelReview', () => {
+ it('throws a typed error carrying the server error message on {success:false}', async () => {
+ cancelMutateMock.mockResolvedValue({
+ success: false,
+ error: 'Review cannot be cancelled in its current state.',
+ });
+ const opts = getOptions('cancel');
+
+ // The thrown error must be a plain Error whose .message is the server's
+ // data.error verbatim — useCodeReviewer.ts's pattern uses a generic
+ // literal that would regress the user-facing message; this hook keeps
+ // the domain reason intact so toast.error(error.message) shows it.
+ try {
+ await opts.mutationFn?.({ reviewId: 'r1' });
+ throw new Error('mutationFn should have rejected');
+ } catch (err) {
+ expect(err).toBeInstanceOf(Error);
+ expect((err as Error).message).toBe('Review cannot be cancelled in its current state.');
+ }
+ });
+
+ it('resolves with the full success payload so the mutation lifecycle continues normally', async () => {
+ const successPayload = { success: true, review: { id: 'r1', status: 'cancelled' } };
+ cancelMutateMock.mockResolvedValueOnce(successPayload);
+ const opts = getOptions('cancel');
+
+ await expect(opts.mutationFn?.({ reviewId: 'r1' })).resolves.toEqual(successPayload);
+ });
+
+ it('toasts the thrown error message via onError and does NOT call onSuccess on failure', async () => {
+ cancelMutateMock.mockResolvedValue({
+ success: false,
+ error: 'Already completed',
+ });
+ const opts = getOptions('cancel');
+
+ let thrown: unknown = null;
+ try {
+ await opts.mutationFn?.({ reviewId: 'r1' });
+ } catch (err) {
+ thrown = err;
+ opts.onError?.(err);
+ }
+
+ expect(thrown).toBeInstanceOf(Error);
+ expect((thrown as Error).message).toBe('Already completed');
+ expect(toastErrorMock).toHaveBeenCalledWith('Already completed');
+ // onSuccess must not have run on the failure path — that's the live
+ // defect the slice fixes (per-call cancel haptic on a failed cancel).
+ expect(invalidateQueriesMock).not.toHaveBeenCalled();
+ });
+
+ it('invalidates the review list and detail on real success', () => {
+ const opts = getOptions('cancel');
+ opts.onSuccess?.({ success: true, review: { id: 'r1' } }, { reviewId: 'r1' });
+
+ // useInvalidateReviews calls invalidateQueries once for the list key
+ // and again for the detail key when a reviewId is provided.
+ expect(invalidateQueriesMock).toHaveBeenCalledTimes(2);
+ expect(toastErrorMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('useRetriggerReview', () => {
+ it('throws a typed error carrying the server error message on {success:false}', async () => {
+ retriggerMutateMock.mockResolvedValueOnce({
+ success: false,
+ error: 'Repository not connected',
+ });
+ const opts = getOptions('retrigger');
+
+ await expect(opts.mutationFn?.({ reviewId: 'r2' })).rejects.toThrow('Repository not connected');
+ });
+
+ it('resolves with the full success payload on success', async () => {
+ const successPayload = { success: true, review: { id: 'r2', status: 'queued' } };
+ retriggerMutateMock.mockResolvedValueOnce(successPayload);
+ const opts = getOptions('retrigger');
+
+ await expect(opts.mutationFn?.({ reviewId: 'r2' })).resolves.toEqual(successPayload);
+ });
+
+ it('toasts the thrown error message via onError and does NOT call onSuccess on failure', async () => {
+ retriggerMutateMock.mockResolvedValueOnce({
+ success: false,
+ error: 'Provider rate limit hit',
+ });
+ const opts = getOptions('retrigger');
+
+ try {
+ await opts.mutationFn?.({ reviewId: 'r2' });
+ } catch (err) {
+ opts.onError?.(err);
+ }
+
+ expect(toastErrorMock).toHaveBeenCalledWith('Provider rate limit hit');
+ expect(invalidateQueriesMock).not.toHaveBeenCalled();
+ });
+
+ it('invalidates the review list and detail on real success', () => {
+ const opts = getOptions('retrigger');
+ opts.onSuccess?.({ success: true }, { reviewId: 'r2' });
+
+ expect(invalidateQueriesMock).toHaveBeenCalledTimes(2);
+ expect(toastErrorMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('useCreateManualReview', () => {
+ it('throws a typed error carrying the server error message on {success:false} (personal scope)', async () => {
+ const opts = getOptions('create', 'personal');
+ personalCreateMutateMock.mockResolvedValue({
+ success: false,
+ error: 'Invalid pull request URL',
+ });
+
+ try {
+ await opts.mutationFn?.({
+ platform: 'github',
+ url: 'https://github.com/foo/bar/pull/1',
+ modelSlug: 'claude-opus-4-7',
+ });
+ throw new Error('mutationFn should have rejected');
+ } catch (err) {
+ expect(err).toBeInstanceOf(Error);
+ expect((err as Error).message).toBe('Invalid pull request URL');
+ }
+ });
+
+ it('throws a typed error carrying the server error message on {success:false} (org scope)', async () => {
+ const opts = getOptions('create', 'org_42');
+ orgCreateMutateMock.mockResolvedValue({
+ success: false,
+ error: 'Provider not connected for organization',
+ });
+
+ try {
+ await opts.mutationFn?.({
+ platform: 'gitlab',
+ url: 'https://gitlab.com/g/p/-/merge_requests/1',
+ modelSlug: 'claude-opus-4-7',
+ });
+ throw new Error('mutationFn should have rejected');
+ } catch (err) {
+ expect((err as Error).message).toBe('Provider not connected for organization');
+ }
+ expect(orgCreateMutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({ organizationId: 'org_42' })
+ );
+ });
+
+ it('resolves with the full success payload (including reviewId) so caller navigation works', async () => {
+ const opts = getOptions('create', 'personal');
+ const successPayload = { success: true as const, reviewId: 'rev_abc123' };
+ personalCreateMutateMock.mockResolvedValue(successPayload);
+
+ const resolved = (await opts.mutationFn?.({
+ platform: 'github',
+ url: 'https://github.com/foo/bar/pull/1',
+ modelSlug: 'claude-opus-4-7',
+ })) as { success: true; reviewId: string };
+
+ // The screen destructures `{ reviewId }` from onSuccess's argument to
+ // navigate — verify that the payload still carries the full success
+ // shape with `reviewId` (the defect the slice fixes was navigating with
+ // `reviewId` undefined because the mutationFn used to resolve on
+ // {success:false}).
+ expect(resolved.reviewId).toBe('rev_abc123');
+ });
+
+ it('toasts the thrown error message via onError and does NOT call onSuccess on failure', async () => {
+ const opts = getOptions('create', 'personal');
+ personalCreateMutateMock.mockResolvedValue({
+ success: false,
+ error: 'Insufficient balance',
+ });
+
+ let thrown: unknown = null;
+ try {
+ await opts.mutationFn?.({
+ platform: 'github',
+ url: 'https://github.com/foo/bar/pull/1',
+ modelSlug: 'claude-opus-4-7',
+ });
+ } catch (err) {
+ thrown = err;
+ opts.onError?.(err);
+ }
+
+ expect((thrown as Error).message).toBe('Insufficient balance');
+ expect(toastErrorMock).toHaveBeenCalledWith('Insufficient balance');
+ expect(invalidateQueriesMock).not.toHaveBeenCalled();
+ });
+
+ it('invalidates the list (no detail) on real success', () => {
+ const opts = getOptions('create', 'personal');
+ opts.onSuccess?.({ success: true, reviewId: 'rev_abc123' }, undefined);
+
+ // useInvalidateReviews with no reviewId only invalidates the list.
+ expect(invalidateQueriesMock).toHaveBeenCalledTimes(1);
+ expect(toastErrorMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-code-reviews.ts b/apps/mobile/src/lib/hooks/use-code-reviews.ts
index e72aadbe77..c4a71e72fa 100644
--- a/apps/mobile/src/lib/hooks/use-code-reviews.ts
+++ b/apps/mobile/src/lib/hooks/use-code-reviews.ts
@@ -68,14 +68,20 @@ export function useCancelReview(scope: string) {
const invalidateReviews = useInvalidateReviews(scope);
return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: { reviewId: string }) =>
- trpcClient.codeReviews.cancel.mutate({ reviewId: vars.reviewId }),
- onSuccess: (data, vars) => {
- if (!data.success) {
- toast.error(data.error);
- return;
+ mutationFn: async (vars: { reviewId: string }) => {
+ // `success` is typed as `boolean` (not a `true` literal), so a domain
+ // failure here must not be treated as a resolved mutation — throwing
+ // routes it to onError (toast) instead of letting callers' onSuccess
+ // fire haptics/navigation as if it worked. The error carries the
+ // server's `data.error` verbatim so toast.error(error.message) shows
+ // the domain reason instead of a generic literal.
+ const result = await trpcClient.codeReviews.cancel.mutate({ reviewId: vars.reviewId });
+ if (!result.success) {
+ throw new Error(result.error);
}
+ return result;
+ },
+ onSuccess: (_data, vars) => {
invalidateReviews(vars.reviewId);
},
onError: error => {
@@ -88,14 +94,16 @@ export function useRetriggerReview(scope: string) {
const invalidateReviews = useInvalidateReviews(scope);
return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: { reviewId: string }) =>
- trpcClient.codeReviews.retrigger.mutate({ reviewId: vars.reviewId }),
- onSuccess: (data, vars) => {
- if (!data.success) {
- toast.error(data.error);
- return;
+ mutationFn: async (vars: { reviewId: string }) => {
+ // Same typed-error pattern as useCancelReview: a domain failure throws
+ // so React Query runs onError (toast) rather than onSuccess (haptic).
+ const result = await trpcClient.codeReviews.retrigger.mutate({ reviewId: vars.reviewId });
+ if (!result.success) {
+ throw new Error(result.error);
}
+ return result;
+ },
+ onSuccess: (_data, vars) => {
invalidateReviews(vars.reviewId);
},
onError: error => {
@@ -108,20 +116,33 @@ export function useCreateManualReview(scope: string) {
const invalidateReviews = useInvalidateReviews(scope);
return useMutation({
- // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
- mutationFn: (vars: {
+ mutationFn: async (vars: {
platform: 'github' | 'gitlab';
url: string;
modelSlug: string;
thinkingEffort?: string | null;
instructions?: string;
- }) =>
- isPersonal(scope)
- ? trpcClient.personalReviewAgent.createManualReviewJob.mutate(vars)
- : trpcClient.organizations.reviewAgent.createManualReviewJob.mutate({
+ }) => {
+ // Same typed-error pattern: a domain failure throws so the screen's
+ // per-call onSuccess (haptic + router.replace to the new review)
+ // does not run with `reviewId` undefined. The full success payload
+ // (including `reviewId`) still resolves on real success so caller
+ // navigation keeps working.
+ const result = isPersonal(scope)
+ ? await trpcClient.personalReviewAgent.createManualReviewJob.mutate(vars)
+ : await trpcClient.organizations.reviewAgent.createManualReviewJob.mutate({
...vars,
organizationId: scope,
- }),
+ });
+ // The create router resolves with the job result directly (no
+ // `{success, error}` envelope) or throws — this check is defensive
+ // against the `{success: false}` shape other code-reviews mutations
+ // use, so a domain failure here still routes to onError.
+ if (!(result as { success?: boolean }).success) {
+ throw new Error((result as { error?: string }).error);
+ }
+ return result;
+ },
onSuccess: () => {
invalidateReviews();
},
From dd37fb4d1ebf8b41825bacdc715ce34209d1ecd7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 03:29:17 +0200
Subject: [PATCH 02/19] fix(mobile): gate PR merge success on authoritative
merged result
---
.../pr-merge-partial-success-banner.test.ts | 33 +++
.../merge/pr-merge-partial-success-banner.tsx | 27 ++
.../pr-review/merge/pr-merge-sheet.test.tsx | 267 ++++++++++++++++++
.../pr-review/merge/pr-merge-sheet.tsx | 17 +-
.../components/pr-review/pr-review-screen.tsx | 19 ++
.../merge/merge-result-banner-store.test.ts | 64 +++++
.../merge/merge-result-banner-store.ts | 50 ++++
.../merge/merge-result-error.test.ts | 33 +++
.../lib/pr-review/merge/merge-result-error.ts | 25 ++
.../pr-review/merge/merge-result-gate.test.ts | 104 +++++++
.../lib/pr-review/merge/merge-result-gate.ts | 89 ++++++
.../merge/use-pr-merge-mutations.test.ts | 152 ++++++++++
.../pr-review/merge/use-pr-merge-mutations.ts | 54 +++-
apps/mobile/vitest.config.ts | 1 +
.../routers/github-pr-review-router.test.ts | 15 +
15 files changed, 937 insertions(+), 13 deletions(-)
create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-partial-success-banner.test.ts
create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-partial-success-banner.tsx
create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-banner-store.test.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-banner-store.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-error.test.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-error.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-gate.test.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts
create mode 100644 apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-partial-success-banner.test.ts b/apps/mobile/src/components/pr-review/merge/pr-merge-partial-success-banner.test.ts
new file mode 100644
index 0000000000..a1a6f8916a
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-partial-success-banner.test.ts
@@ -0,0 +1,33 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+const BANNER_SOURCE = readFileSync(
+ fileURLToPath(new URL('./pr-merge-partial-success-banner.tsx', import.meta.url)),
+ 'utf8'
+);
+
+describe('PrMergePartialSuccessBanner', () => {
+ it('renders the merge-success headline and the branch-delete failure reason', () => {
+ // The banner is a small, pure presentational component. Source-level
+ // assertions are enough to lock the contract: (a) the merge itself
+ // is presented as successful, (b) the failure reason is interpolated,
+ // (c) an accessibilityLabel stitches the two together for screen
+ // readers, and (d) there is NO button — the user already merged and
+ // there is no client-side retry / undo.
+ expect(BANNER_SOURCE).toContain('Merged');
+ expect(BANNER_SOURCE).toContain("Couldn't delete the branch: ${reason}");
+ expect(BANNER_SOURCE).toContain('accessibilityLabel=');
+ expect(BANNER_SOURCE).toContain('accessibilityLiveRegion="polite"');
+ });
+
+ it('contains NO Button or Pressable (no destructive CTA — there is nothing to retry or undo)', () => {
+ // The simplest way to assert "this component cannot render a
+ // destructive action": if it imported `@/components/ui/button` or
+ // `Pressable`, that would be a regression.
+ expect(BANNER_SOURCE).not.toMatch(/from\s+['"]@\/components\/ui\/button['"]/);
+ expect(BANNER_SOURCE).not.toMatch(/
+ ) : null
+ }
+ />
From 7c181e2c34ed516844ad447c5f37d8c08bcd4312 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 06:24:58 +0200
Subject: [PATCH 08/19] refactor(web,app-shared): share one bot fix-command
parser
P1-F-47a: the auto-fix review-comment webhook admitted fix requests with two
local regexes (/@kilo\\b/i + /\\b(fix|patch)\\b/i). The mention regex rejected
the product-advertised '@kilocode-bot fix it' footer command (the \\b fails
inside 'kilocode'), so the exact command the inline-comment footer tells users
to use never triggered Auto Fix.
- Add shared pure parser packages/app-shared/src/code-review/mention-command.ts
exporting parseFixCommand(text): boolean, broadened to /@kilo[\\w-]*/i so it
admits @kilo, @kilocode, and @kilocode-bot while still requiring a fix/patch
keyword; exported from the code-review barrel.
- The webhook processor consumes parseFixCommand instead of its local regexes
(same reject path/log preserved).
- Drift guard: default-prompt-template.json is unchanged; a new apps/web test
reads the inlineCommentFooter literal and asserts parseFixCommand admits the
advertised command, so footer/parser divergence fails the build. (Placed in
apps/web, not app-shared, because the shared package cannot import the
apps/web template JSON.)
Tests: shared parser unit tests (advertised + shorthand + negatives); webhook
processor delegation/admit-reject tests; drift-guard. Inversion-checked
(narrowing the parser fails the advertised + drift-guard tests). Green:
typecheck, root lint/format, app-shared (201), web suites (48, incl.
generate-prompt unchanged).
---
.../review-comment-webhook-processor.test.ts | 155 ++++++++++++++++++
.../review-comment-webhook-processor.ts | 13 +-
...efault-prompt-template.drift-guard.test.ts | 42 +++++
packages/app-shared/src/code-review/index.ts | 1 +
.../src/code-review/mention-command.test.ts | 58 +++++++
.../src/code-review/mention-command.ts | 34 ++++
6 files changed, 298 insertions(+), 5 deletions(-)
create mode 100644 apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.test.ts
create mode 100644 apps/web/src/lib/code-reviews/prompts/default-prompt-template.drift-guard.test.ts
create mode 100644 packages/app-shared/src/code-review/mention-command.test.ts
create mode 100644 packages/app-shared/src/code-review/mention-command.ts
diff --git a/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.test.ts b/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.test.ts
new file mode 100644
index 0000000000..654a2d6bd8
--- /dev/null
+++ b/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.test.ts
@@ -0,0 +1,155 @@
+/**
+ * Unit tests for the auto-fix review-comment webhook processor's
+ * admission branch. Focuses on the previously-buggy local-regex check
+ * (which rejected the product-advertised "@kilocode-bot fix it" command)
+ * and the new shared `parseFixCommand` admission.
+ *
+ * The downstream dispatch path (permission, agent config, ticket
+ * creation, dispatch) is intentionally NOT exercised here — it has its
+ * own coverage and would require many more mocks. The single side
+ * effect we observe is the first function called after admission
+ * (`getAgentConfigForOwner`), so when admission is rejected the
+ * function returns silently with no further calls; when admission is
+ * granted, the mock throws and the test catches the throw.
+ */
+import type { PlatformIntegration } from '@kilocode/db/schema';
+import type { PullRequestReviewCommentPayload } from '@/lib/integrations/platforms/github/webhook-schemas';
+
+const mockGetAgentConfigForOwner = jest.fn();
+const mockFindExistingReviewCommentFixTicket = jest.fn();
+const mockParseFixCommand = jest.fn();
+
+jest.mock('@/lib/agent-config/db/agent-configs', () => ({
+ getAgentConfigForOwner: (...args: unknown[]) => mockGetAgentConfigForOwner(...args),
+}));
+
+jest.mock('../../db/fix-tickets', () => ({
+ createFixTicket: jest.fn(),
+ findExistingReviewCommentFixTicket: (...args: unknown[]) =>
+ mockFindExistingReviewCommentFixTicket(...args),
+ resetFixTicketForRetry: jest.fn(),
+}));
+
+jest.mock('../../dispatch/dispatch-pending-fixes', () => ({
+ tryDispatchPendingFixes: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('@/lib/bot-users/bot-user-service', () => ({
+ getBotUserId: jest.fn(),
+}));
+
+jest.mock('@/lib/integrations/platforms/github/adapter', () => ({
+ addReactionToPRReviewComment: jest.fn().mockResolvedValue(undefined),
+ getCollaboratorPermissionLevel: jest.fn(),
+}));
+
+jest.mock('@sentry/nextjs', () => ({
+ captureException: jest.fn(),
+}));
+
+jest.mock('@kilocode/app-shared/code-review', () => ({
+ parseFixCommand: (text: string) => mockParseFixCommand(text),
+}));
+
+import { ReviewCommentWebhookProcessor } from './review-comment-webhook-processor';
+
+function buildPayload(body: string): PullRequestReviewCommentPayload {
+ return {
+ action: 'created',
+ comment: {
+ id: 1,
+ body,
+ user: { login: 'maintainer' },
+ in_reply_to_id: null,
+ created_at: '2026-07-23T00:00:00.000Z',
+ html_url: 'https://github.com/acme/widgets/pull/42#discussion_r1',
+ path: 'src/widget.ts',
+ line: 10,
+ diff_hunk: '@@',
+ // MEMBER is in WRITE_ACCESS_ASSOCIATIONS so the permission API
+ // fallback (also mocked) is skipped.
+ author_association: 'MEMBER',
+ },
+ pull_request: {
+ number: 42,
+ title: 'Test PR',
+ html_url: 'https://github.com/acme/widgets/pull/42',
+ user: { login: 'contributor' },
+ head: { sha: 'abc123', ref: 'feature' },
+ base: { ref: 'main' },
+ },
+ repository: {
+ id: 1,
+ name: 'widgets',
+ full_name: 'acme/widgets',
+ private: true,
+ owner: { login: 'acme' },
+ },
+ installation: { id: 123 },
+ sender: { login: 'maintainer' },
+ };
+}
+
+const integration = {
+ id: 'integration-1',
+ owned_by_user_id: 'user-1',
+ owned_by_organization_id: null,
+ github_app_type: 'standard',
+} as unknown as PlatformIntegration;
+
+describe('ReviewCommentWebhookProcessor admission', () => {
+ let processor: ReviewCommentWebhookProcessor;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ processor = new ReviewCommentWebhookProcessor();
+ });
+
+ it('admits the product-advertised @kilocode-bot fix it command (regression evidence)', async () => {
+ // Real shared parser behavior is asserted in the shared package's
+ // mention-command.test.ts. Here we verify the processor delegates
+ // admission to the shared parser and proceeds to the next step.
+ const body = '@kilocode-bot fix it';
+ mockParseFixCommand.mockReturnValue(true);
+ // First call after admission — when this throws we know admission
+ // was granted.
+ mockGetAgentConfigForOwner.mockRejectedValue(new Error('admitted'));
+
+ await expect(processor.process(buildPayload(body), integration)).rejects.toThrow('admitted');
+
+ expect(mockParseFixCommand).toHaveBeenCalledWith(body);
+ expect(mockGetAgentConfigForOwner).toHaveBeenCalledTimes(1);
+ });
+
+ it('admits the existing shorthand @kilo fix', async () => {
+ const body = '@kilo fix this';
+ mockParseFixCommand.mockReturnValue(true);
+ mockGetAgentConfigForOwner.mockRejectedValue(new Error('admitted'));
+
+ await expect(processor.process(buildPayload(body), integration)).rejects.toThrow('admitted');
+
+ expect(mockParseFixCommand).toHaveBeenCalledWith(body);
+ expect(mockGetAgentConfigForOwner).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a non-matching body without calling getAgentConfigForOwner', async () => {
+ const body = 'please fix this';
+ mockParseFixCommand.mockReturnValue(false);
+
+ await processor.process(buildPayload(body), integration);
+
+ expect(mockParseFixCommand).toHaveBeenCalledWith(body);
+ expect(mockGetAgentConfigForOwner).not.toHaveBeenCalled();
+ expect(mockFindExistingReviewCommentFixTicket).not.toHaveBeenCalled();
+ });
+
+ it('rejects a mention-only body (no fix keyword) without further processing', async () => {
+ const body = '@kilocode-bot ship it';
+ mockParseFixCommand.mockReturnValue(false);
+
+ await processor.process(buildPayload(body), integration);
+
+ expect(mockParseFixCommand).toHaveBeenCalledWith(body);
+ expect(mockGetAgentConfigForOwner).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.ts b/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.ts
index 2d6f384808..03c969e5ff 100644
--- a/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.ts
+++ b/apps/web/src/lib/auto-fix/application/webhook/review-comment-webhook-processor.ts
@@ -30,9 +30,7 @@ import type {
PullRequestReviewCommentPayload,
GitHubAuthorAssociation,
} from '@/lib/integrations/platforms/github/webhook-schemas';
-
-const KILO_MENTION_PATTERN = /@kilo\b/i;
-const FIX_KEYWORD_PATTERN = /\b(fix|patch)\b/i;
+import { parseFixCommand } from '@kilocode/app-shared/code-review';
/**
* author_association values that imply write access.
@@ -71,8 +69,13 @@ export class ReviewCommentWebhookProcessor {
commentId: comment.id,
});
- // 1. Check if comment body contains @kilo and a fix keyword
- if (!KILO_MENTION_PATTERN.test(comment.body) || !FIX_KEYWORD_PATTERN.test(comment.body)) {
+ // 1. Check if comment body contains @kilo and a fix keyword.
+ // Admission is delegated to the shared parseFixCommand so the
+ // product-advertised "@kilocode-bot fix it" footer command and
+ // the existing "@kilo … fix" shorthand both admit (and a
+ // mention-only or fix-only body still rejects). See
+ // @kilocode/app-shared/code-review/mention-command.ts.
+ if (!parseFixCommand(comment.body)) {
logExceptInTest('[ReviewCommentWebhookProcessor] No @kilo fix mention found', {
commentId: comment.id,
});
diff --git a/apps/web/src/lib/code-reviews/prompts/default-prompt-template.drift-guard.test.ts b/apps/web/src/lib/code-reviews/prompts/default-prompt-template.drift-guard.test.ts
new file mode 100644
index 0000000000..0ff34c0862
--- /dev/null
+++ b/apps/web/src/lib/code-reviews/prompts/default-prompt-template.drift-guard.test.ts
@@ -0,0 +1,42 @@
+/**
+ * Drift-guard test: the prompt-template footer literally tells users
+ * "Reply with `@kilocode-bot fix it` to have Kilo Code address this
+ * issue", and the auto-fix webhook processor is supposed to admit
+ * exactly that command. This test reads the JSON footer literal
+ * verbatim, asserts the footer still contains the advertised command
+ * string, and asserts the shared `parseFixCommand` parser admits it.
+ *
+ * Placement: this test lives in apps/web (not in @kilocode/app-shared)
+ * because the shared package cannot import a JSON file that lives in
+ * apps/web, while apps/web can import both the template JSON and the
+ * shared parser. Co-locating the footer literal and the parser
+ * assertion in the same test makes any future divergence — a footer
+ * wording change, a parser narrowing, or a mention-pattern
+ * simplification that re-breaks the advertised command — fail
+ * immediately.
+ */
+import defaultPromptTemplate from './default-prompt-template.json';
+import { parseFixCommand } from '@kilocode/app-shared/code-review';
+
+const ADVERTISED_COMMAND = '@kilocode-bot fix it';
+
+describe('default-prompt-template inlineCommentFooter drift guard', () => {
+ const footer = defaultPromptTemplate.inlineCommentFooter;
+
+ it('still advertises the exact @kilocode-bot fix it command', () => {
+ expect(footer).toContain(ADVERTISED_COMMAND);
+ });
+
+ it('the shared parseFixCommand admits the exact advertised command', () => {
+ expect(parseFixCommand(ADVERTISED_COMMAND)).toBe(true);
+ });
+
+ it('parseFixCommand also admits a representative command embedded in the footer text', () => {
+ // Sanity check: extracting a representative admit-command from the
+ // footer literal and running it through the parser must still admit.
+ // If both the footer wording and the parser ever drift, this fails.
+ const sample = footer.split('\n').find(line => line.includes('@kilocode-bot'));
+ expect(sample).toBeDefined();
+ expect(parseFixCommand(sample!)).toBe(true);
+ });
+});
diff --git a/packages/app-shared/src/code-review/index.ts b/packages/app-shared/src/code-review/index.ts
index 989526b042..366d87bdf3 100644
--- a/packages/app-shared/src/code-review/index.ts
+++ b/packages/app-shared/src/code-review/index.ts
@@ -2,3 +2,4 @@ export * from './enums';
export * from './status';
export * from './links';
export * from './config';
+export * from './mention-command';
diff --git a/packages/app-shared/src/code-review/mention-command.test.ts b/packages/app-shared/src/code-review/mention-command.test.ts
new file mode 100644
index 0000000000..d1979afae9
--- /dev/null
+++ b/packages/app-shared/src/code-review/mention-command.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseFixCommand } from './mention-command';
+
+describe('parseFixCommand', () => {
+ describe('admits', () => {
+ it('admits the product-advertised @kilocode-bot fix it command', () => {
+ // The exact command string the inlineCommentFooter in
+ // apps/web/src/lib/code-reviews/prompts/default-prompt-template.json
+ // asks users to reply with. Regression guard: the previous
+ // /@kilo\b/i pattern rejected this because \b does not match
+ // between letters of "kilocode".
+ expect(parseFixCommand('@kilocode-bot fix it')).toBe(true);
+ });
+
+ it('admits the existing shorthand @kilo please fix', () => {
+ expect(parseFixCommand('@kilo please fix')).toBe(true);
+ });
+
+ it('admits the existing shorthand @kilo patch this', () => {
+ expect(parseFixCommand('@kilo patch this')).toBe(true);
+ });
+
+ it('admits @kilocode (no -bot suffix) with a fix keyword', () => {
+ expect(parseFixCommand('@kilocode can you fix this?')).toBe(true);
+ });
+
+ it('is case-insensitive for both mention and fix keyword', () => {
+ expect(parseFixCommand('@KiloCode-Bot FIX it')).toBe(true);
+ expect(parseFixCommand('@KILO Patch this')).toBe(true);
+ });
+
+ it('admits when the mention and fix keyword appear in either order', () => {
+ expect(parseFixCommand('Please fix this @kilocode-bot thanks')).toBe(true);
+ });
+ });
+
+ describe('rejects', () => {
+ it('rejects a mention without a fix keyword', () => {
+ expect(parseFixCommand('@kilocode-bot ship it')).toBe(false);
+ expect(parseFixCommand('@kilo ship it')).toBe(false);
+ });
+
+ it('rejects a fix keyword without a mention', () => {
+ expect(parseFixCommand('please fix this')).toBe(false);
+ expect(parseFixCommand('patch this thing')).toBe(false);
+ });
+
+ it('rejects an empty string', () => {
+ expect(parseFixCommand('')).toBe(false);
+ });
+
+ it('rejects unrelated text', () => {
+ expect(parseFixCommand('Looks good to me!')).toBe(false);
+ expect(parseFixCommand('LGTM, merging.')).toBe(false);
+ });
+ });
+});
diff --git a/packages/app-shared/src/code-review/mention-command.ts b/packages/app-shared/src/code-review/mention-command.ts
new file mode 100644
index 0000000000..ca11019e90
--- /dev/null
+++ b/packages/app-shared/src/code-review/mention-command.ts
@@ -0,0 +1,34 @@
+/**
+ * Shared parser that decides whether a free-form text body (typically a
+ * GitHub PR review comment) should be admitted as a request for Kilo to
+ * auto-fix the issue it discusses.
+ *
+ * Why this lives in @kilocode/app-shared and not in the webhook consumer:
+ * the canonical "what command admits a fix" rule has to stay in lock-step
+ * with the user-facing footer that the code-review prompt advertises in
+ * inline review comments (see
+ * apps/web/src/lib/code-reviews/prompts/default-prompt-template.json,
+ * field inlineCommentFooter). The drift-guard test in apps/web
+ * (default-prompt-template.drift-guard.test.ts) reads that footer literal
+ * and asserts this parser still admits the exact command it advertises, so a
+ * future change to the footer or the parser that breaks the contract fails
+ * the test instead of silently regressing the product.
+ *
+ * The mention pattern is deliberately broadened from the previous strict
+ * one (which rejected the product-advertised "@kilocode-bot fix it"
+ * because the word-boundary assertion did not match between the letters of
+ * "kilocode"): the new pattern admits @kilo, @kilocode, and
+ * @kilocode-bot (and any future @kilo… variant) while still requiring a
+ * fix-or-patch keyword. A bare "fix" or "patch" with no @kilo* mention is
+ * rejected so unrelated comment text does not trigger Auto Fix.
+ */
+
+const MENTION_PATTERN = /@kilo[\w-]*/i;
+const FIX_KEYWORD_PATTERN = /\b(?:fix|patch)\b/i;
+
+export function parseFixCommand(text: string): boolean {
+ if (typeof text !== 'string' || text.length === 0) {
+ return false;
+ }
+ return MENTION_PATTERN.test(text) && FIX_KEYWORD_PATTERN.test(text);
+}
From 68e895c8e88a267c6f59a1795ee43e15f0cbac11 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 06:33:08 +0200
Subject: [PATCH 09/19] fix(web): query PR review-thread reactions via
reactionGroups
P0-C-14: REVIEW_THREADS_QUERY and REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY
selected count/reactors/viewerHasReacted under reactions.nodes (the Reaction
type, which has none of those fields), so GitHub rejected the whole document
and review threads never loaded. Switch both queries to reactionGroups (one
group per ReactionContent) and realign GraphQlReactionNode / GraphQlCommentNode
and normalizeReactions to read reactors.totalCount.
The output DTO { content, count, viewerHasReacted } is byte-for-byte unchanged
(same order, no new filtering), so mappers.ts and the mobile reactions row are
unaffected. Schema-validity of all raw docs is proven in Wave 4 (P0-H-14).
Tests: new normalize-reactions.test.ts pins the DTO invariant against a
synthetic reactionGroups payload (count from reactors.totalCount, null->0,
order/one-per-content, viewerHasReacted passthrough); inversion-checked.
Green: typecheck, root lint/format, web github-pr-review (90).
---
.../normalize-reactions.test.ts | 106 ++++++++++++++++++
.../review-thread-comments.test.ts | 2 +-
.../src/routers/github-pr-review-router.ts | 44 ++++----
3 files changed, 131 insertions(+), 21 deletions(-)
create mode 100644 apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
diff --git a/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts b/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
new file mode 100644
index 0000000000..43c3c9ae2c
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
@@ -0,0 +1,106 @@
+/**
+ * @jest-environment node
+ *
+ * Pins the reaction DTO invariant for `normalizeReactions` / `normalizeComment`
+ * (P0-C-14). The router now selects GitHub's `reactionGroups` field, which
+ * returns a flat `ReactionGroup[]` (one entry per `ReactionContent`) with
+ * `reactors.totalCount`. The output DTO consumed by
+ * `apps/web/src/lib/github-pr-review/mappers.ts` and the mobile reactions
+ * row MUST be byte-for-byte equivalent to the previous shape:
+ * `Array<{ content: string; count: number; viewerHasReacted: boolean }>`,
+ * preserving order and not filtering zero-count groups.
+ */
+import {
+ normalizeComment_FOR_TEST,
+ normalizeReactions_FOR_TEST,
+} from '@/routers/github-pr-review-router';
+
+describe('normalizeReactions (reactionGroups shape)', () => {
+ it('maps a group with reactors.totalCount to { content, count, viewerHasReacted }', () => {
+ const out = normalizeReactions_FOR_TEST([
+ {
+ content: '+1',
+ viewerHasReacted: true,
+ reactors: { totalCount: 3 },
+ },
+ ]);
+ expect(out).toEqual([{ content: '+1', count: 3, viewerHasReacted: true }]);
+ });
+
+ it('treats absent/null reactors as count: 0 (and never throws)', () => {
+ expect(
+ normalizeReactions_FOR_TEST([
+ { content: 'THUMBS_UP', viewerHasReacted: false, reactors: null },
+ ])
+ ).toEqual([{ content: 'THUMBS_UP', count: 0, viewerHasReacted: false }]);
+
+ // `reactors` omitted entirely — same behavior.
+ expect(normalizeReactions_FOR_TEST([{ content: 'HEART', viewerHasReacted: false }])).toEqual([
+ { content: 'HEART', count: 0, viewerHasReacted: false },
+ ]);
+ });
+
+ it('preserves order with one entry per distinct content and does not drop zero-count groups', () => {
+ const out = normalizeReactions_FOR_TEST([
+ { content: '+1', viewerHasReacted: true, reactors: { totalCount: 2 } },
+ { content: 'LAUGH', viewerHasReacted: false, reactors: { totalCount: 0 } },
+ { content: 'HEART', viewerHasReacted: false, reactors: { totalCount: 7 } },
+ ]);
+ expect(out).toEqual([
+ { content: '+1', count: 2, viewerHasReacted: true },
+ // Zero-count group is preserved exactly as it appears in the source —
+ // the previous shape also did not filter these, so callers relying
+ // on the reactions row would silently change.
+ { content: 'LAUGH', count: 0, viewerHasReacted: false },
+ { content: 'HEART', count: 7, viewerHasReacted: false },
+ ]);
+ expect(out.map(r => r.content)).toEqual(['+1', 'LAUGH', 'HEART']);
+ });
+
+ it('coerces a truthy non-boolean viewerHasReacted to true (legacy GitHub quirk)', () => {
+ // The legacy normalizeReactions wrapper called `Boolean(...)`; preserve
+ // that contract even when GitHub occasionally returns truthy non-booleans.
+ const out = normalizeReactions_FOR_TEST([
+ { content: 'ROCKET', viewerHasReacted: 1 as unknown as boolean, reactors: { totalCount: 1 } },
+ ]);
+ expect(out[0]?.viewerHasReacted).toBe(true);
+ });
+
+ it('returns an empty array for an empty input (no spurious entries)', () => {
+ expect(normalizeReactions_FOR_TEST([])).toEqual([]);
+ });
+});
+
+describe('normalizeComment (reactionGroups shape)', () => {
+ it('reads node.reactionGroups and forwards the same DTO shape', () => {
+ const out = normalizeComment_FOR_TEST({
+ databaseId: 42,
+ id: 'node_42',
+ body: 'hello',
+ createdAt: '2024-01-01T00:00:00Z',
+ author: { login: 'octocat', avatarUrl: 'https://x/y.png' },
+ reactionGroups: [
+ { content: '+1', viewerHasReacted: false, reactors: { totalCount: 1 } },
+ { content: 'EYES', viewerHasReacted: true, reactors: { totalCount: 4 } },
+ ],
+ });
+ expect(out.databaseId).toBe(42);
+ expect(out.reactions).toEqual([
+ { content: '+1', count: 1, viewerHasReacted: false },
+ { content: 'EYES', count: 4, viewerHasReacted: true },
+ ]);
+ });
+
+ it('defaults reactionGroups to [] when the field is absent or null', () => {
+ const out = normalizeComment_FOR_TEST({
+ databaseId: 1,
+ id: 'node_1',
+ body: '',
+ createdAt: '2024-01-01T00:00:00Z',
+ author: null,
+ // `reactionGroups` omitted on purpose.
+ } as unknown as Parameters[0]);
+ expect(out.reactions).toEqual([]);
+ expect(out.author).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
index 9d4ab05059..49f858cbde 100644
--- a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
+++ b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
@@ -13,7 +13,7 @@ function commentNode(id: number) {
body: `comment ${id}`,
createdAt: '2024-01-01T00:00:00Z',
author: { login: 'octocat', avatarUrl: 'https://x/y.png' },
- reactions: { nodes: [] },
+ reactionGroups: [],
};
}
diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts
index 9dfe6eb48d..c219f1c531 100644
--- a/apps/web/src/routers/github-pr-review-router.ts
+++ b/apps/web/src/routers/github-pr-review-router.ts
@@ -235,13 +235,11 @@ const REVIEW_THREADS_QUERY = /* GraphQL */ `
login
avatarUrl
}
- reactions(first: 20) {
- nodes {
- content
- count: reactors(first: 0) {
- totalCount
- }
- viewerHasReacted
+ reactionGroups {
+ content
+ viewerHasReacted
+ reactors(first: 0) {
+ totalCount
}
}
}
@@ -271,13 +269,11 @@ const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY = /* GraphQL */ `
login
avatarUrl
}
- reactions(first: 20) {
- nodes {
- content
- count: reactors(first: 0) {
- totalCount
- }
- viewerHasReacted
+ reactionGroups {
+ content
+ viewerHasReacted
+ reactors(first: 0) {
+ totalCount
}
}
}
@@ -351,8 +347,8 @@ const REMOVE_REACTION_MUTATION = /* GraphQL */ `
type GraphQlReactionNode = {
content: string;
- count?: { totalCount: number } | null;
viewerHasReacted: boolean;
+ reactors?: { totalCount: number } | null;
};
type GraphQlCommentNode = {
@@ -361,7 +357,7 @@ type GraphQlCommentNode = {
body: string;
createdAt: string;
author: { login: string; avatarUrl: string } | null;
- reactions: { nodes: GraphQlReactionNode[] };
+ reactionGroups: GraphQlReactionNode[];
};
type GraphQlCommentConnection = {
@@ -383,10 +379,10 @@ type GraphQlReviewThreadNode = {
comments: GraphQlCommentConnection;
};
-function normalizeReactions(nodes: GraphQlReactionNode[]) {
- return nodes.map(n => ({
+function normalizeReactions(groups: GraphQlReactionNode[]) {
+ return groups.map(n => ({
content: n.content,
- count: n.count?.totalCount ?? 0,
+ count: n.reactors?.totalCount ?? 0,
viewerHasReacted: Boolean(n.viewerHasReacted),
}));
}
@@ -398,13 +394,21 @@ function normalizeComment(node: GraphQlCommentNode) {
body: node.body,
createdAt: node.createdAt,
author: node.author,
- reactions: normalizeReactions(node.reactions?.nodes ?? []),
+ reactions: normalizeReactions(node.reactionGroups ?? []),
};
}
// Exported for unit testing the follow-up pagination loop.
export const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST = REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY;
+// Exported for unit testing the reaction DTO invariant pinned against
+// GitHub's actual `reactionGroups` shape. The downstream DTO contract —
+// `Array<{ content: string; count: number; viewerHasReacted: boolean }>` —
+// is consumed by `mappers.ts` and the mobile reactions row and must NOT
+// change shape; see `normalize-reactions.test.ts`.
+export const normalizeReactions_FOR_TEST = normalizeReactions;
+export const normalizeComment_FOR_TEST = normalizeComment;
+
export async function fetchAllThreadComments(args: {
octokit: ReturnType;
threadId: string;
From 03a4cfb5ae26b83a48cee2652e0df9f10c61847b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 06:51:25 +0200
Subject: [PATCH 10/19] fix(mobile): save Code Reviewer config via field-merge
patch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0-B-13b: useSaveReviewConfig sent a full-document saveReviewConfig built from
the cached ReviewConfigData, which erased council / manuallyAddedRepositories /
councilEnabledRepositoryIds — fields the mobile client never loads. Route the
save through the field-merge patchReviewConfig endpoints (P0-B-13a) with only
the edited fields, so unlisted fields are preserved server-side.
- Personal/org patch payload is { platform, ...editedFields } (+ organizationId
for org); personal selectedRepositoryIds/repositoryModelOverrides still
narrowed to numeric ids and only sent when the patch carries them (no empty-
array synthesis). GitLab autoConfigureWebhooks:true sent only when the patch
includes selectedRepositoryIds (matches server webhook-sync gating).
- chainSave FIFO, throw-on-!success, GitLab webhook warning, onMutate/onError/
onSettled all preserved.
- Removed the now-unused buildSaveConfigInput helper (+ its shared tests and the
mobile re-export); check:unused clean.
Tests: mobile unit test asserts the partial-patch shape (only edited fields,
not a full doc) + onError toast (inversion-checked); new web integration cases
in both patchReviewConfig suites seed council/manuallyAdded/councilEnabled and
drive a mobile-shaped patch through the real procedure, asserting they survive.
Green: typecheck, root lint/format, mobile hook test (10), app-shared (194),
web routers (72), check:unused.
---
apps/mobile/src/lib/code-reviewer-config.ts | 1 -
.../src/lib/hooks/use-code-reviewer.test.ts | 368 ++++++++++++++++++
.../mobile/src/lib/hooks/use-code-reviewer.ts | 96 +++--
.../src/routers/code-reviews-router.test.ts | 65 ++++
.../organization-code-reviews-router.test.ts | 72 ++++
.../app-shared/src/code-review/config.test.ts | 151 ++-----
packages/app-shared/src/code-review/config.ts | 61 +--
7 files changed, 606 insertions(+), 208 deletions(-)
create mode 100644 apps/mobile/src/lib/hooks/use-code-reviewer.test.ts
diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts
index 7188a97bc4..0930154b89 100644
--- a/apps/mobile/src/lib/code-reviewer-config.ts
+++ b/apps/mobile/src/lib/code-reviewer-config.ts
@@ -6,7 +6,6 @@ import {
import { parseParam } from '@/lib/route-params';
export {
- buildSaveConfigInput,
GATE_THRESHOLDS,
REVIEW_FOCUS_AREAS,
REVIEW_STYLES,
diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts
new file mode 100644
index 0000000000..138b2368ac
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-code-reviewer.test.ts
@@ -0,0 +1,368 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { type ConfigPatch, PERSONAL_SCOPE } from '@/lib/code-reviewer-config';
+
+import { useSaveReviewConfig } from './use-code-reviewer';
+
+type MutationOptions = {
+ mutationFn?: (vars: unknown) => Promise;
+ onError?: (error: unknown) => void;
+ onSettled?: () => void;
+ onSuccess?: (data: unknown) => void;
+};
+
+type PersonalPatch = {
+ platform: string;
+ reviewStyle?: string;
+ focusAreas?: string[];
+ customInstructions?: string;
+ modelSlug?: string;
+ thinkingEffort?: string | null;
+ gateThreshold?: string;
+ repositorySelectionMode?: string;
+ selectedRepositoryIds?: (number | string)[];
+ repositoryModelOverrides?: {
+ repositoryId: number | string;
+ repoFullName: string;
+ modelSlug: string;
+ thinkingEffort?: string | null;
+ }[];
+ disableReviewMd?: boolean;
+ autoConfigureWebhooks?: boolean;
+};
+
+type OrgPatch = PersonalPatch & { organizationId: string };
+
+const personalPatchMutateMock = vi.fn();
+const orgPatchMutateMock = vi.fn();
+const personalSaveMutateMock = vi.fn();
+const orgSaveMutateMock = vi.fn();
+const invalidateQueriesMock = vi.fn();
+const cancelQueriesMock = vi.fn();
+const getQueryDataMock = vi.fn();
+const setQueryDataMock = vi.fn();
+const toastErrorMock = vi.fn();
+
+let lastCapturedOptions: MutationOptions | null = null;
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: (opts: MutationOptions) => {
+ lastCapturedOptions = opts;
+ return { mutate: vi.fn() };
+ },
+ useQuery: () => ({ data: undefined }),
+ useQueryClient: () => ({
+ cancelQueries: cancelQueriesMock,
+ getQueryData: getQueryDataMock,
+ setQueryData: setQueryDataMock,
+ invalidateQueries: invalidateQueriesMock,
+ }),
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ personalReviewAgent: {
+ getReviewConfig: { queryKey: () => ['personalReviewAgent', 'getReviewConfig'] },
+ },
+ organizations: {
+ reviewAgent: {
+ getReviewConfig: { queryKey: () => ['organizations', 'reviewAgent', 'getReviewConfig'] },
+ },
+ },
+ }),
+ trpcClient: {
+ personalReviewAgent: {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ patchReviewConfig: { mutate: (vars: unknown) => personalPatchMutateMock(vars) },
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ saveReviewConfig: { mutate: (vars: unknown) => personalSaveMutateMock(vars) },
+ },
+ organizations: {
+ reviewAgent: {
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ patchReviewConfig: { mutate: (vars: unknown) => orgPatchMutateMock(vars) },
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ saveReviewConfig: { mutate: (vars: unknown) => orgSaveMutateMock(vars) },
+ },
+ },
+ },
+}));
+
+vi.mock('sonner-native', () => ({
+ toast: { error: (msg: string) => toastErrorMock(msg) },
+}));
+
+// use-code-reviewer.ts re-exports from use-reviewer-permission, which
+// imports `useRouter` from expo-router. Loading the real module in node
+// blows up on the expo-router source map, so stub just the surface the
+// re-export's transitive imports actually reach.
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
+}));
+
+function getSaveOptions(
+ scope: string,
+ platform: 'github' | 'gitlab' | 'bitbucket'
+): MutationOptions {
+ lastCapturedOptions = null;
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useSaveReviewConfig(scope, platform);
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!lastCapturedOptions) {
+ throw new Error('mutation options for useSaveReviewConfig were not captured');
+ }
+ return lastCapturedOptions;
+}
+
+beforeEach(() => {
+ lastCapturedOptions = null;
+ personalPatchMutateMock.mockReset();
+ orgPatchMutateMock.mockReset();
+ personalSaveMutateMock.mockReset();
+ orgSaveMutateMock.mockReset();
+ invalidateQueriesMock.mockReset();
+ cancelQueriesMock.mockReset();
+ getQueryDataMock.mockReset();
+ setQueryDataMock.mockReset();
+ toastErrorMock.mockReset();
+ // Default: each patch mutate resolves to a successful payload. Tests
+ // override per-case when they need a different outcome.
+ personalPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null });
+ orgPatchMutateMock.mockResolvedValue({ success: true, webhookSync: null });
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('useSaveReviewConfig mutationFn payload shape', () => {
+ it('sends ONLY edited fields + platform for a personal github patch (NOT a full document)', async () => {
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ const patch: ConfigPatch = { reviewStyle: 'strict' };
+ await opts.mutationFn?.(patch);
+
+ expect(personalPatchMutateMock).toHaveBeenCalledTimes(1);
+ const sent = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ // Platform is always present.
+ expect(sent.platform).toBe('github');
+ // Only the edited key reaches the wire — every other mobile-editable
+ // field must be absent. (A full-doc save would carry all of these.)
+ expect(sent).toEqual({ platform: 'github', reviewStyle: 'strict' });
+ // Explicit negative assertions, since `toEqual` would pass for a
+ // full-doc payload that happens to also include the same keys.
+ expect(sent).not.toHaveProperty('focusAreas');
+ expect(sent).not.toHaveProperty('customInstructions');
+ expect(sent).not.toHaveProperty('modelSlug');
+ expect(sent).not.toHaveProperty('thinkingEffort');
+ expect(sent).not.toHaveProperty('gateThreshold');
+ expect(sent).not.toHaveProperty('repositorySelectionMode');
+ expect(sent).not.toHaveProperty('selectedRepositoryIds');
+ expect(sent).not.toHaveProperty('repositoryModelOverrides');
+ expect(sent).not.toHaveProperty('disableReviewMd');
+ expect(sent).not.toHaveProperty('autoConfigureWebhooks');
+ // And the route family must be the PATCH, not the legacy save.
+ expect(personalSaveMutateMock).not.toHaveBeenCalled();
+ });
+
+ it('sends ONLY edited fields for a personal github multi-field patch', async () => {
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ const patch: ConfigPatch = {
+ reviewStyle: 'lenient',
+ focusAreas: ['security', 'performance'],
+ modelSlug: 'openai/gpt-5',
+ };
+ await opts.mutationFn?.(patch);
+
+ expect(personalPatchMutateMock).toHaveBeenCalledTimes(1);
+ const sent = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ expect(sent).toEqual({
+ platform: 'github',
+ reviewStyle: 'lenient',
+ focusAreas: ['security', 'performance'],
+ modelSlug: 'openai/gpt-5',
+ });
+ expect(sent).not.toHaveProperty('customInstructions');
+ expect(sent).not.toHaveProperty('thinkingEffort');
+ expect(sent).not.toHaveProperty('gateThreshold');
+ expect(sent).not.toHaveProperty('repositorySelectionMode');
+ expect(sent).not.toHaveProperty('selectedRepositoryIds');
+ expect(sent).not.toHaveProperty('repositoryModelOverrides');
+ expect(sent).not.toHaveProperty('disableReviewMd');
+ expect(sent).not.toHaveProperty('autoConfigureWebhooks');
+ });
+
+ it('narrow personal selectedRepositoryIds / repositoryModelOverrides to numeric ids and does not include them when absent from the patch', async () => {
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ // Patch carries both keys with mixed string/number ids (a defensive
+ // shape — the production UI only sends numbers, but the type permits
+ // strings). The personal schema rejects strings, so they must be
+ // filtered out before going on the wire.
+ const patch = {
+ reviewStyle: 'strict' as const,
+ selectedRepositoryIds: [101, 'bitbucket-uuid', 202] as (number | string)[],
+ repositoryModelOverrides: [
+ { repositoryId: 101, repoFullName: 'a/a', modelSlug: 'm', thinkingEffort: null },
+ {
+ repositoryId: 'bitbucket-uuid',
+ repoFullName: 'b/b',
+ modelSlug: 'm',
+ thinkingEffort: null,
+ },
+ ],
+ };
+ await opts.mutationFn?.(patch);
+
+ const sent = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ expect(sent.platform).toBe('github');
+ expect(sent.reviewStyle).toBe('strict');
+ expect(sent.selectedRepositoryIds).toEqual([101, 202]);
+ expect(sent.repositoryModelOverrides).toEqual([
+ { repositoryId: 101, repoFullName: 'a/a', modelSlug: 'm', thinkingEffort: null },
+ ]);
+ });
+
+ it('does not inject selectedRepositoryIds / repositoryModelOverrides when the patch omits them', async () => {
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ await opts.mutationFn?.({ focusAreas: ['security'] });
+
+ const sent = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ expect(sent).toEqual({ platform: 'github', focusAreas: ['security'] });
+ // An empty array would still be a real edit that could clobber stored
+ // values — the hook must not silently synthesize one.
+ expect(sent).not.toHaveProperty('selectedRepositoryIds');
+ expect(sent).not.toHaveProperty('repositoryModelOverrides');
+ });
+
+ it('includes autoConfigureWebhooks on a GitLab personal patch only when selectedRepositoryIds is present', async () => {
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'gitlab');
+
+ // Repo-selection edit: webhook re-sync must run server-side.
+ await opts.mutationFn?.({ selectedRepositoryIds: [101] });
+ const sentSelection = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ expect(sentSelection).toEqual({
+ platform: 'gitlab',
+ selectedRepositoryIds: [101],
+ autoConfigureWebhooks: true,
+ });
+
+ // Unrelated edit: webhook re-sync must NOT run server-side (gated on
+ // selectedRepositoryIds being present in the patch).
+ personalPatchMutateMock.mockClear();
+ await opts.mutationFn?.({ focusAreas: ['security'] });
+ const sentUnrelated = personalPatchMutateMock.mock.calls[0]?.[0] as PersonalPatch;
+ expect(sentUnrelated).toEqual({ platform: 'gitlab', focusAreas: ['security'] });
+ expect(sentUnrelated).not.toHaveProperty('autoConfigureWebhooks');
+ });
+
+ it('sends ONLY edited fields + organizationId + platform for an org github patch', async () => {
+ const opts = getSaveOptions('org_42', 'github');
+
+ const patch: ConfigPatch = { gateThreshold: 'critical' };
+ await opts.mutationFn?.(patch);
+
+ expect(orgPatchMutateMock).toHaveBeenCalledTimes(1);
+ const sent = orgPatchMutateMock.mock.calls[0]?.[0] as OrgPatch;
+ expect(sent).toEqual({
+ organizationId: 'org_42',
+ platform: 'github',
+ gateThreshold: 'critical',
+ });
+ expect(sent).not.toHaveProperty('reviewStyle');
+ expect(sent).not.toHaveProperty('focusAreas');
+ expect(sent).not.toHaveProperty('selectedRepositoryIds');
+ expect(sent).not.toHaveProperty('repositoryModelOverrides');
+ expect(sent).not.toHaveProperty('autoConfigureWebhooks');
+ expect(personalPatchMutateMock).not.toHaveBeenCalled();
+ expect(personalSaveMutateMock).not.toHaveBeenCalled();
+ expect(orgSaveMutateMock).not.toHaveBeenCalled();
+ });
+
+ it('does NOT narrow string-id repository overrides for the org path (org schema accepts both)', async () => {
+ const opts = getSaveOptions('org_42', 'bitbucket');
+
+ const patch: ConfigPatch = {
+ selectedRepositoryIds: ['bitbucket-uuid-1', 'bitbucket-uuid-2'],
+ repositoryModelOverrides: [
+ { repositoryId: 'bitbucket-uuid-1', repoFullName: 'a/a', modelSlug: 'm' },
+ ],
+ };
+ await opts.mutationFn?.(patch);
+
+ const sent = orgPatchMutateMock.mock.calls[0]?.[0] as OrgPatch;
+ // String ids are preserved end-to-end on the org route.
+ expect(sent.selectedRepositoryIds).toEqual(['bitbucket-uuid-1', 'bitbucket-uuid-2']);
+ expect(sent.repositoryModelOverrides).toEqual([
+ { repositoryId: 'bitbucket-uuid-1', repoFullName: 'a/a', modelSlug: 'm' },
+ ]);
+ });
+
+ it('includes autoConfigureWebhooks on a GitLab org patch only when selectedRepositoryIds is present', async () => {
+ const opts = getSaveOptions('org_42', 'gitlab');
+
+ await opts.mutationFn?.({ selectedRepositoryIds: [202, 303] });
+ const sentSelection = orgPatchMutateMock.mock.calls[0]?.[0] as OrgPatch;
+ expect(sentSelection).toEqual({
+ organizationId: 'org_42',
+ platform: 'gitlab',
+ selectedRepositoryIds: [202, 303],
+ autoConfigureWebhooks: true,
+ });
+
+ orgPatchMutateMock.mockClear();
+ await opts.mutationFn?.({ focusAreas: ['security'] });
+ const sentUnrelated = orgPatchMutateMock.mock.calls[0]?.[0] as OrgPatch;
+ expect(sentUnrelated).toEqual({
+ organizationId: 'org_42',
+ platform: 'gitlab',
+ focusAreas: ['security'],
+ });
+ expect(sentUnrelated).not.toHaveProperty('autoConfigureWebhooks');
+ });
+});
+
+describe('useSaveReviewConfig onError', () => {
+ it('toasts the thrown error message and does not call invalidateQueries from onError', async () => {
+ personalPatchMutateMock.mockReset();
+ personalPatchMutateMock.mockResolvedValue({
+ success: false,
+ webhookSync: null,
+ });
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ let thrown: unknown = null;
+ try {
+ await opts.mutationFn?.({ reviewStyle: 'strict' });
+ } catch (error) {
+ thrown = error;
+ opts.onError?.(error);
+ }
+
+ expect(thrown).toBeInstanceOf(Error);
+ expect((thrown as Error).message).toBe('Failed to save review config');
+ expect(toastErrorMock).toHaveBeenCalledWith('Failed to save review config');
+ // onSettled is the only place invalidateQueries should fire; onError
+ // must not also invalidate (would clobber a follow-up optimistic save).
+ expect(invalidateQueriesMock).not.toHaveBeenCalled();
+ });
+
+ it('propagates a transport-level rejection from patchReviewConfig verbatim and still toasts it', async () => {
+ personalPatchMutateMock.mockReset();
+ personalPatchMutateMock.mockRejectedValue(new Error('Network unreachable'));
+ const opts = getSaveOptions(PERSONAL_SCOPE, 'github');
+
+ let thrown: unknown = null;
+ try {
+ await opts.mutationFn?.({ reviewStyle: 'strict' });
+ } catch (error) {
+ thrown = error;
+ opts.onError?.(error);
+ }
+
+ expect((thrown as Error).message).toBe('Network unreachable');
+ expect(toastErrorMock).toHaveBeenCalledWith('Network unreachable');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts
index 4a98178dc6..fe9d0fc6d8 100644
--- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts
+++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tan
import { toast } from 'sonner-native';
import {
- buildSaveConfigInput,
type ConfigPatch,
PERSONAL_SCOPE,
type ReviewConfigData,
@@ -220,37 +219,80 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) {
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
mutationFn: (patch: ConfigPatch) =>
// Rapid taps (e.g. toggling several focus areas in a row) each send a
- // full-config snapshot; without serializing them, two in-flight saves
- // for the same scope+platform can resolve out of order and the
- // earlier response can stomp the later one's result. Chaining onto
- // the prior in-flight save for this key keeps them in order — simple
- // FIFO, no dedupe/coalescing.
+ // PATCH; without serializing them, two in-flight saves for the same
+ // scope+platform can resolve out of order and the earlier response can
+ // stomp the later one's optimistic state. Chaining onto the prior
+ // in-flight save for this key keeps them in order — simple FIFO, no
+ // dedupe/coalescing.
chainSave(saveChainKey, async () => {
- const config = queryClient.getQueryData(queryKey);
- if (!config) {
- throw new Error('Config not loaded yet');
- }
- const input = buildSaveConfigInput(platform, config, patch);
- // The personal schema only accepts numeric repository IDs (bitbucket,
- // the only string-ID platform, is org-only). Filtering keeps this a
- // type-safe narrowing rather than a cast; the personal branch is only
- // ever reached with platform !== 'bitbucket' in practice.
- const result = isPersonal(scope)
- ? await trpcClient.personalReviewAgent.saveReviewConfig.mutate({
- ...input,
- platform: toPersonalPlatform(platform),
- selectedRepositoryIds: input.selectedRepositoryIds.filter(
- (id): id is number => typeof id === 'number'
- ),
- // Same numeric-only narrowing as selectedRepositoryIds above.
- repositoryModelOverrides: input.repositoryModelOverrides.filter(
+ // The PATCH only carries edited fields. Server-side field-merge
+ // preserves every key absent from the patch, so the mobile client
+ // does not need to read back the full config (or any of the
+ // org-only/council/manuallyAddedRepositories fields it never loaded)
+ // just to send a partial update.
+ //
+ // Pull each optional field off the patch individually (rather than
+ // spreading `patch` first) so the personal tRPC option type can see
+ // the already-narrowed numeric arrays — `ConfigPatch` permits
+ // string-id repository overrides (bitbucket is org-only by UI
+ // construction, but the shared type still allows them), and the
+ // personal PATCH schema only accepts numbers.
+ const {
+ selectedRepositoryIds: rawSelectedRepositoryIds,
+ repositoryModelOverrides: rawRepositoryModelOverrides,
+ ...restPatch
+ } = patch;
+ // The personal schema only accepts numeric repository IDs
+ // (bitbucket, the only string-ID platform, is org-only). Filtering
+ // keeps this a type-safe narrowing rather than a cast; the
+ // personal branch is only ever reached with platform !==
+ // 'bitbucket' in practice. Only include these keys when the
+ // incoming patch actually carries them — an empty array would
+ // still be a real edit and could clobber stored values.
+ const narrowedSelectedRepositoryIds =
+ rawSelectedRepositoryIds !== undefined
+ ? rawSelectedRepositoryIds.filter((id): id is number => typeof id === 'number')
+ : undefined;
+ const narrowedRepositoryModelOverrides =
+ rawRepositoryModelOverrides !== undefined
+ ? rawRepositoryModelOverrides.filter(
(override): override is typeof override & { repositoryId: number } =>
typeof override.repositoryId === 'number'
- ),
+ )
+ : undefined;
+ // GitLab webhook re-sync is gated server-side on
+ // `selectedRepositoryIds` being present in the patch; we send
+ // `autoConfigureWebhooks: true` (mobile has no toggle) to match
+ // the prior always-true save behavior when a repo selection edit
+ // is part of the patch.
+ const gitlabAutoConfigure =
+ platform === 'gitlab' && rawSelectedRepositoryIds !== undefined
+ ? ({ autoConfigureWebhooks: true } as const)
+ : ({} as const);
+
+ const result = isPersonal(scope)
+ ? await trpcClient.personalReviewAgent.patchReviewConfig.mutate({
+ platform: toPersonalPlatform(platform),
+ ...restPatch,
+ ...(narrowedSelectedRepositoryIds !== undefined
+ ? { selectedRepositoryIds: narrowedSelectedRepositoryIds }
+ : {}),
+ ...(narrowedRepositoryModelOverrides !== undefined
+ ? { repositoryModelOverrides: narrowedRepositoryModelOverrides }
+ : {}),
+ ...gitlabAutoConfigure,
})
- : await trpcClient.organizations.reviewAgent.saveReviewConfig.mutate({
- ...input,
+ : await trpcClient.organizations.reviewAgent.patchReviewConfig.mutate({
organizationId: scope,
+ platform,
+ ...restPatch,
+ ...(rawSelectedRepositoryIds !== undefined
+ ? { selectedRepositoryIds: rawSelectedRepositoryIds }
+ : {}),
+ ...(rawRepositoryModelOverrides !== undefined
+ ? { repositoryModelOverrides: rawRepositoryModelOverrides }
+ : {}),
+ ...gitlabAutoConfigure,
});
// Same reasoning as useToggleReviewer: `success` is typed as `boolean`,
// not a `true` literal, so a domain failure must throw rather than
diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts
index 84bc4ea2de..c1e2a3de0a 100644
--- a/apps/web/src/routers/code-reviews-router.test.ts
+++ b/apps/web/src/routers/code-reviews-router.test.ts
@@ -2500,4 +2500,69 @@ describe('personalReviewAgent.patchReviewConfig', () => {
'https://gitlab.example.com'
);
});
+
+ // P0-B-13b: real mobile→server contract guard. The mobile
+ // useSaveReviewConfig hook now sends a partial patch whose shape is
+ // exactly { platform, ...editedFields } — no manuallyAddedRepositories,
+ // no repositoryModelOverrides, no autoConfigureWebhooks. The server
+ // PATCH must field-merge those absent keys from the stored config.
+ it('preserves a config seeded with manuallyAddedRepositories + overrides when a mobile-shaped patch is applied', async () => {
+ await seedPersonalGithubConfig();
+ const caller = await createCallerForUser(testUser.id);
+
+ // Mobile-shaped patch: ONLY the keys the mobile UI lets the user edit.
+ // The personal PATCH schema does not even accept
+ // manuallyAddedRepositories / repositoryModelOverrides /
+ // autoConfigureWebhooks here — the contract guard is that the server
+ // never asks for them.
+ await caller.personalReviewAgent.patchReviewConfig({
+ platform: 'github',
+ reviewStyle: 'strict',
+ focusAreas: ['security'],
+ });
+
+ const stored = await db.query.agent_configs.findFirst({
+ where: and(
+ eq(agent_configs.agent_type, 'code_review'),
+ eq(agent_configs.owned_by_user_id, testUser.id)
+ ),
+ });
+
+ // Patched fields applied.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ review_style: 'strict',
+ focus_areas: ['security'],
+ })
+ );
+ // Stored fields NOT in the mobile-shaped patch must round-trip
+ // unchanged. The personal schema has no council/councilEnabled, so
+ // the mobile contract is specifically about manuallyAddedRepositories
+ // and repositoryModelOverrides.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ manually_added_repositories: [
+ { id: 9, name: 'manual', full_name: 'manual/repo', private: true },
+ ],
+ repository_model_overrides: [
+ {
+ repository_id: 101,
+ repo_full_name: 'acme/api',
+ model_slug: 'openai/gpt-5',
+ thinking_effort: 'high',
+ },
+ ],
+ })
+ );
+ // Other stored fields that the mobile client never read or sent must
+ // also be preserved.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ selected_repository_ids: [101, 202],
+ repository_selection_mode: 'all',
+ gate_threshold: 'off',
+ disable_review_md: true,
+ })
+ );
+ });
});
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
index 2f1afabc66..5c70872b72 100644
--- a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
+++ b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
@@ -355,4 +355,76 @@ describe('organization review agent router: patchReviewConfig', () => {
expect(logs[0]?.message).toMatch(/^Patched Review Agent configuration for github/);
expect(logs[0]?.message).toContain('roast');
});
+
+ // P0-B-13b: real mobile→server contract guard. The mobile
+ // useSaveReviewConfig hook now sends a partial org patch whose shape
+ // is exactly { organizationId, platform, ...editedFields } — no
+ // manuallyAddedRepositories, no council, no councilEnabledRepositoryIds,
+ // no autoConfigureWebhooks. The server PATCH must field-merge those
+ // absent keys from the stored config so mobile edits do not clobber
+ // org-only state the mobile UI does not surface.
+ it('preserves a config seeded with council + manuallyAddedRepositories + councilEnabledRepositoryIds when a mobile-shaped patch is applied', async () => {
+ const { owner, organization } = await createFixtureOrganization();
+ await seedOrgGithubConfig(organization, owner);
+ const caller = await createCallerForUser(owner.id);
+
+ // Mobile-shaped patch: ONLY the keys the mobile UI lets the user
+ // edit. council, councilEnabledRepositoryIds, manuallyAddedRepositories,
+ // and repositoryModelOverrides are ALL absent — the server must
+ // preserve them.
+ await caller.organizations.reviewAgent.patchReviewConfig({
+ organizationId: organization.id,
+ platform: 'github',
+ reviewStyle: 'strict',
+ focusAreas: ['security'],
+ });
+
+ const stored = await getAgentConfig(organization.id, 'code_review', 'github');
+
+ // Patched fields applied.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ review_style: 'strict',
+ focus_areas: ['security'],
+ })
+ );
+ // Stored fields NOT in the mobile-shaped patch must round-trip
+ // unchanged. The org schema accepts council /
+ // councilEnabledRepositoryIds / manuallyAddedRepositories, but mobile
+ // never sends them — the field-merge must keep them as-is.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ council: expect.objectContaining({
+ enabled: true,
+ aggregation_strategy: 'unanimous',
+ specialists: expect.arrayContaining([
+ expect.objectContaining({ id: 'security' }),
+ expect.objectContaining({ id: 'performance' }),
+ ]),
+ }),
+ council_enabled_repository_ids: [101, 202],
+ manually_added_repositories: [
+ { id: 9, name: 'manual', full_name: 'manual/repo', private: true },
+ ],
+ repository_model_overrides: [
+ {
+ repository_id: 101,
+ repo_full_name: 'acme/api',
+ model_slug: 'openai/gpt-5',
+ thinking_effort: 'high',
+ },
+ ],
+ })
+ );
+ // Other stored fields that the mobile client never read or sent must
+ // also be preserved.
+ expect(stored?.config).toEqual(
+ expect.objectContaining({
+ selected_repository_ids: [101, 202],
+ repository_selection_mode: 'all',
+ gate_threshold: 'off',
+ disable_review_md: true,
+ })
+ );
+ });
});
diff --git a/packages/app-shared/src/code-review/config.test.ts b/packages/app-shared/src/code-review/config.test.ts
index 48c99bc966..83e7fdc310 100644
--- a/packages/app-shared/src/code-review/config.test.ts
+++ b/packages/app-shared/src/code-review/config.test.ts
@@ -1,137 +1,46 @@
import { describe, expect, it } from 'vitest';
-import {
- applyCodeReviewConfigPatch,
- buildSaveConfigInput,
- type CodeReviewConfigInput,
- type CodeReviewStoredConfig,
-} from './config';
+import { applyCodeReviewConfigPatch, type CodeReviewStoredConfig } from './config';
-// Moved from apps/mobile/src/lib/code-reviewer-config.test.ts — assertions
-// kept identical, only the imported type name changed (ReviewConfigData ->
-// CodeReviewConfigInput, this module's structural equivalent).
-const config: CodeReviewConfigInput = {
+// Stored config snapshot covering every field the helper knows about, so each
+// test can assert "this field is preserved" cleanly.
+const stored: CodeReviewStoredConfig = {
reviewStyle: 'balanced',
- focusAreas: ['bugs', 'security'],
- customInstructions: null,
+ focusAreas: ['bugs'],
+ customInstructions: 'be terse',
modelSlug: 'anthropic/claude-sonnet-5',
thinkingEffort: null,
gateThreshold: 'off',
repositorySelectionMode: 'all',
- selectedRepositoryIds: [],
- repositoryModelOverrides: [],
+ selectedRepositoryIds: [101, 202],
+ repositoryModelOverrides: [
+ {
+ repositoryId: 101,
+ repoFullName: 'acme/api',
+ modelSlug: 'openai/gpt-5',
+ thinkingEffort: 'high',
+ },
+ ],
disableReviewMd: true,
-};
-
-describe('buildSaveConfigInput', () => {
- it('carries the full current config for an untouched field', () => {
- const input = buildSaveConfigInput('github', config, { reviewStyle: 'strict' });
- expect(input).toEqual({
- platform: 'github',
- reviewStyle: 'strict',
- focusAreas: ['bugs', 'security'],
- customInstructions: undefined,
- modelSlug: 'anthropic/claude-sonnet-5',
- thinkingEffort: null,
- gateThreshold: 'off',
- repositorySelectionMode: 'all',
- selectedRepositoryIds: [],
- repositoryModelOverrides: [],
- disableReviewMd: true,
- });
- });
-
- it('preserves repository model overrides across an unrelated patch', () => {
- const overrides = [
- {
- repositoryId: 123,
- repoFullName: 'acme/api',
- modelSlug: 'anthropic/claude-opus-4.8',
- thinkingEffort: null,
- },
- ];
- const input = buildSaveConfigInput(
- 'github',
- { ...config, repositoryModelOverrides: overrides },
- { reviewStyle: 'strict' }
- );
- expect(input.repositoryModelOverrides).toEqual(overrides);
- });
-
- it('applies patches over current values', () => {
- const input = buildSaveConfigInput('github', config, {
- focusAreas: ['performance'],
- customInstructions: 'be nice',
- });
- expect(input.focusAreas).toEqual(['performance']);
- expect(input.customInstructions).toBe('be nice');
- expect(input.reviewStyle).toBe('balanced');
- });
-
- it('includes autoConfigureWebhooks for gitlab', () => {
- const input = buildSaveConfigInput('gitlab', config, {});
- expect(input.platform).toBe('gitlab');
- expect(input.autoConfigureWebhooks).toBe(true);
- });
-
- it('carries string repository ids for bitbucket', () => {
- const input = buildSaveConfigInput('bitbucket', config, {
- selectedRepositoryIds: ['uuid-1'],
- });
- expect(input.platform).toBe('bitbucket');
- expect(input.selectedRepositoryIds).toEqual(['uuid-1']);
- });
-
- it('forces selected repository mode for gitlab even when config default is all', () => {
- const input = buildSaveConfigInput('gitlab', config, {});
- expect(input.repositorySelectionMode).toBe('selected');
- });
-
- it('forces selected repository mode for bitbucket even when config default is all', () => {
- const input = buildSaveConfigInput('bitbucket', config, {});
- expect(input.repositorySelectionMode).toBe('selected');
- });
-});
-
-describe('applyCodeReviewConfigPatch', () => {
- // A fully-populated stored snapshot covering every field the helper knows
- // about, so each test can assert "this field is preserved" cleanly.
- const stored: CodeReviewStoredConfig = {
- reviewStyle: 'balanced',
- focusAreas: ['bugs'],
- customInstructions: 'be terse',
- modelSlug: 'anthropic/claude-sonnet-5',
- thinkingEffort: null,
- gateThreshold: 'off',
- repositorySelectionMode: 'all',
- selectedRepositoryIds: [101, 202],
- repositoryModelOverrides: [
+ manuallyAddedRepositories: [{ id: 9, name: 'manual', full_name: 'manual/repo', private: true }],
+ council: {
+ enabled: true,
+ aggregation_strategy: 'unanimous',
+ specialists: [
{
- repositoryId: 101,
- repoFullName: 'acme/api',
- modelSlug: 'openai/gpt-5',
- thinkingEffort: 'high',
+ id: 'security',
+ role: 'security',
+ name: 'Security',
+ enabled: true,
+ required: false,
+ lens: 'audit',
},
],
- disableReviewMd: true,
- manuallyAddedRepositories: [{ id: 9, name: 'manual', full_name: 'manual/repo', private: true }],
- council: {
- enabled: true,
- aggregation_strategy: 'unanimous',
- specialists: [
- {
- id: 'security',
- role: 'security',
- name: 'Security',
- enabled: true,
- required: false,
- lens: 'audit',
- },
- ],
- },
- councilEnabledRepositoryIds: [101, 202],
- };
+ },
+ councilEnabledRepositoryIds: [101, 202],
+};
+describe('applyCodeReviewConfigPatch', () => {
it('preserves every field of `stored` when the patch is empty', () => {
const merged = applyCodeReviewConfigPatch(stored, {});
expect(merged).toEqual(stored);
diff --git a/packages/app-shared/src/code-review/config.ts b/packages/app-shared/src/code-review/config.ts
index c5d25112ec..47bc7c0618 100644
--- a/packages/app-shared/src/code-review/config.ts
+++ b/packages/app-shared/src/code-review/config.ts
@@ -1,4 +1,4 @@
-import type { CodeReviewPlatform, GateThreshold, ReviewStyle } from './enums';
+import type { GateThreshold, ReviewStyle } from './enums';
// Wire shape of a per-repository model override. Mirrors the tRPC input/output
// contract (camelCase); the persisted snake_case shape lives in
@@ -44,25 +44,8 @@ export type CodeReviewCouncilConfigInput = {
}>;
};
-// Structural shape of the review config a save request is built from —
-// matches apps/mobile/src/lib/code-reviewer-config.ts's ReviewConfigData
-// (mobile keeps that name/type locally, derived from its tRPC query output;
-// this is only the subset buildSaveConfigInput actually reads).
-export type CodeReviewConfigInput = {
- reviewStyle: ReviewStyle;
- focusAreas: string[];
- customInstructions: string | null;
- modelSlug: string;
- thinkingEffort: string | null;
- gateThreshold: GateThreshold;
- repositorySelectionMode: 'all' | 'selected';
- selectedRepositoryIds: (number | string)[];
- repositoryModelOverrides: RepositoryModelOverrideInput[];
- disableReviewMd: boolean;
-};
-
// Mobile/personal-org save patch. All keys are optional; omission preserves the
-// stored value. buildSaveConfigInput spreads this into the save payload, so
+// stored value. The PATCH route handlers spread this into the saved payload, so
// it MUST NOT carry org-only field-merge fields (manuallyAddedRepositories,
// council, councilEnabledRepositoryIds) that the strict saveReviewConfig schemas
// do not accept.
@@ -111,46 +94,6 @@ export type CodeReviewStoredConfig = {
councilEnabledRepositoryIds?: (number | string)[];
};
-// Ported verbatim from apps/mobile/src/lib/code-reviewer-config.ts.
-//
-// This is mobile-flavored, not a shared web/mobile rule: web's
-// ReviewConfigForm.tsx builds its save payload inline and does NOT force
-// 'selected' repo mode or a fixed autoConfigureWebhooks for gitlab — it
-// exposes autoConfigureWebhooks as a user-toggleable checkbox (default true)
-// and only forces repositorySelectionMode to 'selected' in local UI state
-// (via a useEffect keyed off isGitLab), and it never sends bitbucket at all
-// (ReviewConfigForm's Platform type is 'github' | 'gitlab' only). So this
-// function stays mobile's rule, ported unchanged; web is not adapted to it.
-export function buildSaveConfigInput(
- platform: CodeReviewPlatform,
- config: CodeReviewConfigInput,
- patch: CodeReviewConfigPatch
-) {
- return {
- platform,
- reviewStyle: config.reviewStyle,
- focusAreas: config.focusAreas,
- customInstructions: config.customInstructions ?? undefined,
- modelSlug: config.modelSlug,
- thinkingEffort: config.thinkingEffort,
- gateThreshold: config.gateThreshold,
- // GitLab and Bitbucket only support 'selected' repo mode server-side; the
- // mode picker only exists for github, so force it here instead of relying
- // on a config default that can still be 'all'.
- repositorySelectionMode:
- platform === 'gitlab' || platform === 'bitbucket'
- ? ('selected' as const)
- : config.repositorySelectionMode,
- selectedRepositoryIds: config.selectedRepositoryIds,
- // Preserve web-created overrides across mobile settings edits (mobile has no
- // override editing UI in v1). The server prunes these to the current selection.
- repositoryModelOverrides: config.repositoryModelOverrides,
- disableReviewMd: config.disableReviewMd,
- ...(platform === 'gitlab' ? { autoConfigureWebhooks: true as const } : {}),
- ...patch,
- };
-}
-
// Field-merge helper for the PATCH endpoints (`personalReviewAgent.patchReviewConfig`
// and `organizations.reviewAgent.patchReviewConfig`). Returns a new object
// containing every field of `stored` plus any field explicitly set in `patch`
From ec06b38cc9ec56d665da76e530e17fc2f7fef7b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 07:33:14 +0200
Subject: [PATCH 11/19] fix(web): stop exposing GitLab webhook secret; add
gated rotation
---
.../code-reviews/ReviewAgentPageClient.tsx | 18 +-
.../code-reviews/ReviewAgentPageClient.tsx | 14 -
.../code-reviews/ReviewConfigForm.tsx | 99 +++--
.../src/routers/code-reviews-router.test.ts | 254 +++++++++++
apps/web/src/routers/code-reviews-router.ts | 8 +-
apps/web/src/routers/gitlab-router.ts | 147 ++++++-
...ization-code-reviews-gitlab-rotate.test.ts | 406 ++++++++++++++++++
.../organization-code-reviews-router.ts | 161 ++++++-
8 files changed, 1007 insertions(+), 100 deletions(-)
create mode 100644 apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
diff --git a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx
index f9d7b63323..4e6136e745 100644
--- a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx
+++ b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx
@@ -275,23 +275,7 @@ export function ReviewAgentPageClient({
-
+
diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx
index acf8a2ba9a..a6ac3a20a0 100644
--- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx
+++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx
@@ -405,20 +405,6 @@ export function ReviewAgentPageClient({
organizationId={organizationId}
platform="gitlab"
councilUiEnabled={councilUiEnabled}
- gitlabStatusData={
- gitlabStatusData
- ? {
- connected: gitlabStatusData.connected,
- integration: gitlabStatusData.integration
- ? {
- isValid: gitlabStatusData.integration.isValid,
- webhookSecret: gitlabStatusData.integration.webhookSecret,
- instanceUrl: gitlabStatusData.integration.instanceUrl,
- }
- : undefined,
- }
- : undefined
- }
/>
diff --git a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx
index 73fe67d2f3..6513c5fbab 100644
--- a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx
+++ b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx
@@ -74,19 +74,9 @@ import {
type Platform = 'github' | 'gitlab';
-export type GitLabStatusData = {
- connected: boolean;
- integration?: {
- isValid: boolean;
- webhookSecret?: string;
- instanceUrl?: string;
- };
-};
-
export type ReviewConfigFormProps = {
organizationId?: string;
platform?: Platform;
- gitlabStatusData?: GitLabStatusData;
/** Same gate as the manual council UI: local dev, or an entitled org behind the rollout flag. */
councilUiEnabled?: boolean;
};
@@ -134,7 +124,6 @@ export const REVIEW_STYLES = REVIEW_STYLE_VALUES.map(value => ({
export function ReviewConfigForm({
organizationId,
platform = 'github',
- gitlabStatusData,
councilUiEnabled = false,
}: ReviewConfigFormProps) {
const trpc = useTRPC();
@@ -319,13 +308,45 @@ export function ReviewConfigForm({
}
}, [availableVariants, thinkingEffort]);
- // Mutation for regenerating webhook secret
- const regenerateSecretMutation = useMutation(
+ // Mutation for regenerating webhook secret. The org path is billing-gated
+ // (owner/billing_manager only); the personal path is self-gated. The
+ // secret is returned ONCE on success and never re-fetched from status —
+ // status no longer carries it.
+ const orgRegenerateSecretMutation = useMutation(
+ trpc.organizations.reviewAgent.rotateGitLabWebhookSecret.mutationOptions({
+ onSuccess: data => {
+ setRegeneratedSecret(data.webhookSecret);
+ const updated = data.webhookSync.updated;
+ const errors = data.webhookSync.errors.length;
+ toast.success(
+ errors > 0
+ ? `Webhook secret rotated. ${updated} webhooks updated, ${errors} error(s) — check audit log.`
+ : `Webhook secret rotated. ${updated} webhook(s) re-synced.`
+ );
+ void queryClient.invalidateQueries({
+ queryKey: trpc.organizations.reviewAgent.getGitLabStatus.queryKey({
+ organizationId: organizationId ?? '',
+ }),
+ });
+ },
+ onError: error => {
+ toast.error('Failed to rotate webhook secret', {
+ description: error.message,
+ });
+ },
+ })
+ );
+ const personalRegenerateSecretMutation = useMutation(
trpc.gitlab.regenerateWebhookSecret.mutationOptions({
onSuccess: data => {
setRegeneratedSecret(data.webhookSecret);
- toast.success('Webhook secret regenerated successfully');
- // Invalidate the GitLab status query to refresh the data
+ const updated = data.webhookSync.updated;
+ const errors = data.webhookSync.errors.length;
+ toast.success(
+ errors > 0
+ ? `Webhook secret regenerated. ${updated} webhooks updated, ${errors} error(s) — check audit log.`
+ : `Webhook secret regenerated. ${updated} webhook(s) re-synced.`
+ );
void queryClient.invalidateQueries({
queryKey: trpc.personalReviewAgent.getGitLabStatus.queryKey(),
});
@@ -340,9 +361,17 @@ export function ReviewConfigForm({
const handleRegenerateSecret = () => {
setRegeneratedSecret(null); // Clear any previously shown secret
- regenerateSecretMutation.mutate({});
+ if (organizationId) {
+ orgRegenerateSecretMutation.mutate({ organizationId });
+ } else {
+ personalRegenerateSecretMutation.mutate();
+ }
};
+ const regenerateSecretMutation = organizationId
+ ? orgRegenerateSecretMutation
+ : personalRegenerateSecretMutation;
+
const handleCopyWebhookUrl = async () => {
await navigator.clipboard.writeText(webhookUrl);
setCopiedWebhookUrl(true);
@@ -350,16 +379,10 @@ export function ReviewConfigForm({
setTimeout(() => setCopiedWebhookUrl(false), 2000);
};
- const handleCopyWebhookSecret = async () => {
- const secret = gitlabStatusData?.integration?.webhookSecret;
- if (secret) {
- await navigator.clipboard.writeText(secret);
- setCopiedWebhookSecret(true);
- toast.success('Webhook secret copied to clipboard');
- setTimeout(() => setCopiedWebhookSecret(false), 2000);
- }
- };
-
+ // The status endpoint no longer returns the webhook secret, so the old
+ // `handleCopyWebhookSecret` (which copied the status-provided secret) and
+ // its markup are removed. The only path to view/copy the secret is the
+ // rotate mutation, which returns it once into `regeneratedSecret` below.
const handleCopyRegeneratedSecret = async () => {
if (regeneratedSecret) {
await navigator.clipboard.writeText(regeneratedSecret);
@@ -1253,30 +1276,6 @@ export function ReviewConfigForm({
>
- ) : gitlabStatusData?.integration?.webhookSecret ? (
- <>
-
-
- ••••••••••••••••
-
-
- {copiedWebhookSecret ? (
-
- ) : (
-
- )}
-
-
-
- Use this secret token in your GitLab webhook configuration for
- security.
-
- >
) : (
No webhook secret configured. Click regenerate to create one.
diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts
index c1e2a3de0a..894bd92cec 100644
--- a/apps/web/src/routers/code-reviews-router.test.ts
+++ b/apps/web/src/routers/code-reviews-router.test.ts
@@ -2566,3 +2566,257 @@ describe('personalReviewAgent.patchReviewConfig', () => {
);
});
});
+
+// ============================================================================
+// P1-D-32: GitLab webhook secret handling on the personal surface.
+//
+// Regression guards for two security fixes:
+// 1. `personalReviewAgent.getGitLabStatus` MUST NOT return the webhook
+// secret. (Lower risk than the org path because the caller is the
+// secret owner, but still a status-read leak that this slice removes.)
+// 2. `gitlab.regenerateWebhookSecret` MUST re-sync the Kilo-managed
+// webhooks so the integration keeps working with the new secret. The
+// previous shape persisted a new secret and never re-synced, so live
+// webhooks kept carrying the old secret and stopped validating.
+// ============================================================================
+
+async function seedPersonalGitLabIntegration(userId: string, metadata: Record) {
+ await db.insert(platform_integrations).values({
+ owned_by_user_id: userId,
+ platform: 'gitlab',
+ integration_type: 'oauth',
+ integration_status: 'active',
+ platform_installation_id: `inst-${crypto.randomUUID()}`,
+ metadata: { ...metadata, webhook_secret: 'old-secret-do-not-leak' },
+ });
+}
+
+async function readPersonalWebhookSecret(userId: string): Promise {
+ const row = await db.query.platform_integrations.findFirst({
+ where: and(
+ eq(platform_integrations.owned_by_user_id, userId),
+ eq(platform_integrations.platform, 'gitlab')
+ ),
+ });
+ return (row?.metadata as Record | null)?.webhook_secret as string | undefined;
+}
+
+async function readPersonalConfiguredWebhooks(
+ userId: string
+): Promise> {
+ const row = await db.query.platform_integrations.findFirst({
+ where: and(
+ eq(platform_integrations.owned_by_user_id, userId),
+ eq(platform_integrations.platform, 'gitlab')
+ ),
+ });
+ return (
+ ((row?.metadata as Record | null)?.configured_webhooks as
+ | Record
+ | undefined) ?? {}
+ );
+}
+
+describe('personalReviewAgent.getGitLabStatus P1-D-32 (omits webhook secret)', () => {
+ let testUser: User;
+
+ beforeAll(async () => {
+ testUser = await insertTestUser();
+ });
+
+ beforeEach(() => {
+ mockGetValidGitLabToken.mockReset();
+ mockSyncWebhooksForRepositories.mockReset();
+ });
+
+ afterEach(async () => {
+ await db
+ .delete(platform_integrations)
+ .where(eq(platform_integrations.owned_by_user_id, testUser.id));
+ });
+
+ afterAll(async () => {
+ await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id));
+ });
+
+ it('returns the integration shape WITHOUT webhookSecret for the self caller', async () => {
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: { '101': { hook_id: 9001, created_at: '2026-01-01T00:00:00Z' } },
+ });
+
+ const caller = await createCallerForUser(testUser.id);
+ const status = await caller.personalReviewAgent.getGitLabStatus();
+
+ expect(status.connected).toBe(true);
+ expect(status.integration).toBeDefined();
+ // Regression guard: the secret must NEVER appear in the status
+ // payload, even for the secret's owner. If this assertion fails,
+ // the leak has been re-introduced.
+ expect(status.integration).not.toHaveProperty('webhookSecret');
+ expect((status.integration as Record).webhookSecret).toBeUndefined();
+ // The rest of the shape is preserved (non-secret fields still ship;
+ // account/repositorySelection/installedAt pass through verbatim from the
+ // stored integration row regardless of their concrete values).
+ expect(status.integration).toEqual(
+ expect.objectContaining({
+ isValid: true,
+ instanceUrl: 'https://gitlab.example.com',
+ })
+ );
+ expect(status.integration).toHaveProperty('accountLogin');
+ expect(status.integration).toHaveProperty('repositorySelection');
+ expect(status.integration).toHaveProperty('installedAt');
+ });
+});
+
+describe('gitlab.regenerateWebhookSecret P1-D-32 (self-only, re-syncs)', () => {
+ let testUser: User;
+ let otherUser: User;
+
+ beforeAll(async () => {
+ testUser = await insertTestUser();
+ otherUser = await insertTestUser();
+ });
+
+ beforeEach(() => {
+ mockGetValidGitLabToken.mockReset();
+ mockSyncWebhooksForRepositories.mockReset();
+ // Default sync outcome: every currently-configured repo was "updated"
+ // (mirrors the `previous=[]` "treat all as added" path used by rotate).
+ mockSyncWebhooksForRepositories.mockImplementation(
+ async (_token, _secret, selectedIds, _previous, configuredWebhooks) => {
+ const updatedWebhooks: Record<
+ string,
+ { hook_id: number; created_at: string; updated_at?: string }
+ > = {};
+ for (const id of selectedIds) {
+ const existing = configuredWebhooks[String(id)];
+ updatedWebhooks[String(id)] = {
+ hook_id: existing?.hook_id ?? 1000 + Number(id),
+ created_at: existing?.created_at ?? new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ }
+ return {
+ result: {
+ created: [],
+ updated: selectedIds.map((id: number) => ({ projectId: id, hookId: 1000 + id })),
+ deleted: [],
+ errors: [],
+ },
+ updatedWebhooks,
+ };
+ }
+ );
+ mockGetValidGitLabToken.mockResolvedValue('gitlab-access-token');
+ });
+
+ afterEach(async () => {
+ await db
+ .delete(platform_integrations)
+ .where(eq(platform_integrations.owned_by_user_id, testUser.id));
+ await db
+ .delete(platform_integrations)
+ .where(eq(platform_integrations.owned_by_user_id, otherUser.id));
+ });
+
+ afterAll(async () => {
+ await db.delete(kilocode_users).where(inArray(kilocode_users.id, [testUser.id, otherUser.id]));
+ });
+
+ it('persists a NEW secret, re-syncs webhooks with the new secret and previous=[]', async () => {
+ const configured = {
+ '101': { hook_id: 9001, created_at: '2026-01-01T00:00:00Z' },
+ '202': { hook_id: 9002, created_at: '2026-01-02T00:00:00Z' },
+ };
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+
+ const caller = await createCallerForUser(testUser.id);
+ const result = await caller.gitlab.regenerateWebhookSecret();
+
+ expect(typeof result.webhookSecret).toBe('string');
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSecret).not.toBe('old-secret-do-not-leak');
+
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledTimes(1);
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith(
+ 'gitlab-access-token',
+ result.webhookSecret,
+ [101, 202],
+ [],
+ configured,
+ 'https://gitlab.example.com'
+ );
+ expect(result.webhookSync.updated).toBe(2);
+ expect(result.webhookSync.created).toBe(0);
+ expect(result.webhookSync.deleted).toBe(0);
+ expect(result.webhookSync.errors).toEqual([]);
+ expect(result.configuredWebhookCount).toBe(2);
+
+ // Persistence: metadata.webhook_secret is the NEW secret and
+ // metadata.configured_webhooks was updated with the sync output.
+ expect(await readPersonalWebhookSecret(testUser.id)).toBe(result.webhookSecret);
+ const stored = await readPersonalConfiguredWebhooks(testUser.id);
+ expect(Object.keys(stored).sort()).toEqual(['101', '202']);
+ expect(stored['101']?.updated_at).toBeDefined();
+ expect(stored['202']?.updated_at).toBeDefined();
+ });
+
+ it('with empty configured_webhooks returns the new secret and does NOT call sync', async () => {
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: {},
+ });
+
+ const caller = await createCallerForUser(testUser.id);
+ const result = await caller.gitlab.regenerateWebhookSecret();
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSync).toEqual({
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [],
+ });
+ expect(result.configuredWebhookCount).toBe(0);
+ expect(mockSyncWebhooksForRepositories).not.toHaveBeenCalled();
+ // No token lookup needed when there are no webhooks to re-sync.
+ expect(mockGetValidGitLabToken).not.toHaveBeenCalled();
+ expect(await readPersonalWebhookSecret(testUser.id)).toBe(result.webhookSecret);
+ });
+
+ it('is self-only: the callers own integration is rotated, never another users', async () => {
+ const configuredSelf = { '1': { hook_id: 1, created_at: '2026-01-01T00:00:00Z' } };
+ const configuredOther = { '2': { hook_id: 2, created_at: '2026-01-01T00:00:00Z' } };
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: configuredSelf,
+ });
+ await seedPersonalGitLabIntegration(otherUser.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: configuredOther,
+ });
+
+ const caller = await createCallerForUser(testUser.id);
+ const result = await caller.gitlab.regenerateWebhookSecret();
+
+ // Self was rotated; other user is untouched.
+ expect(await readPersonalWebhookSecret(testUser.id)).toBe(result.webhookSecret);
+ expect(await readPersonalWebhookSecret(otherUser.id)).toBe('old-secret-do-not-leak');
+
+ // Sync was called only once, for the caller's configured repo id.
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledTimes(1);
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith(
+ 'gitlab-access-token',
+ result.webhookSecret,
+ [1],
+ [],
+ configuredSelf,
+ 'https://gitlab.com'
+ );
+ });
+});
diff --git a/apps/web/src/routers/code-reviews-router.ts b/apps/web/src/routers/code-reviews-router.ts
index f8dfbc9774..35f96272ff 100644
--- a/apps/web/src/routers/code-reviews-router.ts
+++ b/apps/web/src/routers/code-reviews-router.ts
@@ -202,9 +202,12 @@ export const personalReviewAgentRouter = createTRPCRouter({
};
}
- // Extract webhook secret from metadata for display
+ // NOTE: The webhook secret is intentionally NOT returned here. The
+ // previous shape leaked it on every status read (self-only, but still
+ // a status-read leak). The secret is now surfaced only via the
+ // self-gated `gitlab.regenerateWebhookSecret` mutation (returned
+ // once, on demand). See P1-D-32.
const metadata = integration.metadata as Record | null;
- const webhookSecret = metadata?.webhook_secret as string | undefined;
return {
connected: true,
@@ -213,7 +216,6 @@ export const personalReviewAgentRouter = createTRPCRouter({
repositorySelection: integration.repository_access,
installedAt: integration.installed_at,
isValid: true, // GitLab OAuth doesn't have suspension concept
- webhookSecret, // Include webhook secret for user to configure in GitLab
instanceUrl: (metadata?.gitlab_instance_url as string) || 'https://gitlab.com',
},
};
diff --git a/apps/web/src/routers/gitlab-router.ts b/apps/web/src/routers/gitlab-router.ts
index b7b66f3c1c..912d4fa2c6 100644
--- a/apps/web/src/routers/gitlab-router.ts
+++ b/apps/web/src/routers/gitlab-router.ts
@@ -1,7 +1,9 @@
import 'server-only';
import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init';
+import { TRPCError } from '@trpc/server';
import * as z from 'zod';
import * as gitlabService from '@/lib/integrations/gitlab-service';
+import { getValidGitLabToken } from '@/lib/integrations/gitlab-service';
import { ensureOrganizationAccess } from '@/routers/organizations/utils';
import {
resolveOwner,
@@ -12,6 +14,16 @@ import { validateGitLabInstance } from '@/lib/integrations/platforms/gitlab/adap
import { validatePersonalAccessToken } from '@/lib/integrations/platforms/gitlab/adapter';
import { isPlatformIntegrationHealthy } from '@/lib/integrations/core/health';
import { requireNumericPlatformRepositories } from '@/lib/integrations/core/types';
+import {
+ getIntegrationForOwner,
+ updateIntegrationMetadataForOwner,
+} from '@/lib/integrations/db/platform-integrations';
+import {
+ syncWebhooksForRepositories,
+ type ConfiguredWebhook,
+} from '@/lib/integrations/platforms/gitlab/webhook-sync';
+import { logExceptInTest } from '@/lib/utils.server';
+import { randomBytes } from 'node:crypto';
export const gitlabRouter = createTRPCRouter({
/**
@@ -198,17 +210,126 @@ export const gitlabRouter = createTRPCRouter({
);
}),
- regenerateWebhookSecret: baseProcedure
- .input(
- z.object({
- organizationId: z.uuid().optional(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- if (input.organizationId) {
- await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']);
- }
- const owner = resolveOwner(ctx, input.organizationId);
- return gitlabService.regenerateWebhookSecret(owner);
- }),
+ // Personal/self-only GitLab webhook secret rotation. The secret is
+ // returned ONCE on success and never re-fetched from status — status
+ // no longer carries it (see P1-D-32). Org rotation goes through the
+ // dedicated billing-gated `organizations.reviewAgent.rotateGitLabWebhookSecret`
+ // mutation; this endpoint is the caller's own personal integration
+ // only, and re-syncs the Kilo-managed webhooks so the integration
+ // keeps working after the secret change.
+ regenerateWebhookSecret: baseProcedure.mutation(async ({ ctx }) => {
+ // Self-only: resolve the caller's own owner directly. The org
+ // surface uses `organizations.reviewAgent.rotateGitLabWebhookSecret`
+ // with `organizationBillingMutationProcedure` gating.
+ const owner = { type: 'user' as const, id: ctx.user.id };
+
+ // Generate the new secret here so we can re-sync the Kilo-managed
+ // webhooks against the SAME secret in a single operation. The
+ // underlying service-level regen would leave the live webhooks
+ // carrying the old secret, breaking the integration.
+ const newSecret = randomBytes(32).toString('hex');
+
+ const integration = await getIntegrationForOwner(owner, 'gitlab');
+ if (!integration) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'GitLab integration not found',
+ });
+ }
+
+ const existingMetadata = (integration.metadata || {}) as Record;
+ const configuredWebhooks =
+ (existingMetadata.configured_webhooks as Record | undefined) ?? {};
+ const instanceUrl =
+ (existingMetadata.gitlab_instance_url as string | undefined) || 'https://gitlab.com';
+
+ // No Kilo-managed webhooks → skip the network round-trip and just
+ // persist + return the new secret for manual reconfiguration.
+ if (Object.keys(configuredWebhooks).length === 0) {
+ await updateIntegrationMetadataForOwner(owner, 'gitlab', {
+ ...existingMetadata,
+ webhook_secret: newSecret,
+ });
+ return {
+ webhookSecret: newSecret,
+ webhookSync: {
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [] as Array<{ projectId: number; error: string; operation: string }>,
+ },
+ configuredWebhookCount: 0,
+ };
+ }
+
+ let webhookSyncResult: {
+ created: number;
+ updated: number;
+ deleted: number;
+ errors: Array<{ projectId: number; error: string; operation: string }>;
+ } = { created: 0, updated: 0, deleted: 0, errors: [] };
+ let updatedWebhooks: Record = configuredWebhooks;
+
+ try {
+ const accessToken = await getValidGitLabToken(integration, { userId: ctx.user.id });
+ const configuredRepoIds = Object.keys(configuredWebhooks)
+ .map(id => Number.parseInt(id, 10))
+ .filter(id => Number.isFinite(id));
+
+ // previous=[] → every currently-configured repo is treated as
+ // "added" by the sync helper, so the existing Kilo webhook is
+ // UPDATED in place with the new secret (nothing is deleted).
+ const syncOutcome = await syncWebhooksForRepositories(
+ accessToken,
+ newSecret,
+ configuredRepoIds,
+ [],
+ configuredWebhooks,
+ instanceUrl
+ );
+ updatedWebhooks = syncOutcome.updatedWebhooks;
+ webhookSyncResult = {
+ created: syncOutcome.result.created.length,
+ updated: syncOutcome.result.updated.length,
+ deleted: syncOutcome.result.deleted.length,
+ errors: syncOutcome.result.errors,
+ };
+ logExceptInTest('[gitlab.regenerateWebhookSecret] Webhook re-sync completed', {
+ created: webhookSyncResult.created,
+ updated: webhookSyncResult.updated,
+ deleted: webhookSyncResult.deleted,
+ errorCount: webhookSyncResult.errors.length,
+ });
+ } catch (webhookError) {
+ // Re-sync failure MUST NOT lose the new secret: persist it
+ // anyway so the operator can recover via manual reconfiguration.
+ logExceptInTest('[gitlab.regenerateWebhookSecret] Webhook re-sync failed', {
+ error: webhookError instanceof Error ? webhookError.message : String(webhookError),
+ });
+ webhookSyncResult = {
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [
+ {
+ projectId: 0,
+ error: webhookError instanceof Error ? webhookError.message : 'Unknown error',
+ operation: 'create',
+ },
+ ],
+ };
+ }
+
+ await updateIntegrationMetadataForOwner(owner, 'gitlab', {
+ ...existingMetadata,
+ webhook_secret: newSecret,
+ configured_webhooks: updatedWebhooks,
+ });
+
+ return {
+ webhookSecret: newSecret,
+ webhookSync: webhookSyncResult,
+ configuredWebhookCount: Object.keys(updatedWebhooks).length,
+ };
+ }),
});
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts b/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
new file mode 100644
index 0000000000..4e1aad60ec
--- /dev/null
+++ b/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
@@ -0,0 +1,406 @@
+const mockSyncWebhooksForRepositories = jest.fn();
+const mockGetValidGitLabToken = jest.fn();
+
+jest.mock('@/lib/integrations/platforms/gitlab/webhook-sync', () => ({
+ syncWebhooksForRepositories: (...args: unknown[]) => mockSyncWebhooksForRepositories(...args),
+}));
+
+jest.mock('@/lib/integrations/gitlab-service', () => ({
+ getValidGitLabToken: (...args: unknown[]) => mockGetValidGitLabToken(...args),
+}));
+
+// NOTE: `jest` is intentionally NOT imported from '@jest/globals' here. The
+// @swc/jest transform only hoists `jest.mock(...)` above the static imports
+// when `jest` is the global binding; importing it as a local binding disables
+// that hoist, so the mocks below would register AFTER `createCallerForUser`
+// pulls in the real gitlab-service. Using global `jest` keeps the mocks hoisted.
+import { afterAll, beforeEach, describe, expect, it } from '@jest/globals';
+import { createCallerForUser } from '@/routers/test-utils';
+import { insertTestUser } from '@/tests/helpers/user.helper';
+import { createTestOrganization } from '@/tests/helpers/organization.helper';
+import { addUserToOrganization } from '@/lib/organizations/organizations';
+import { db } from '@/lib/drizzle';
+import {
+ kilocode_users,
+ organization_audit_logs,
+ organization_memberships,
+ organizations,
+ platform_integrations,
+} from '@kilocode/db/schema';
+import { and, eq, inArray } from 'drizzle-orm';
+
+const CREATED_ORG_IDS: string[] = [];
+const SEED_USER_IDS: string[] = [];
+
+async function makeOrgAndOwner() {
+ const owner = await insertTestUser();
+ SEED_USER_IDS.push(owner.id);
+ // require_seats=false grants the trial-bypass that
+ // organizationBillingMutationProcedure needs.
+ const organization = await createTestOrganization(
+ `GitLab rotate ${crypto.randomUUID()}`,
+ owner.id,
+ 0,
+ {},
+ false
+ );
+ CREATED_ORG_IDS.push(organization.id);
+ return { owner, organization };
+}
+
+async function seedGitLabIntegration(
+ organizationId: string,
+ metadata: Record
+): Promise<{ id: string; secret: string | undefined }> {
+ const oldSecret = 'old-secret-do-not-leak';
+ const [integration] = await db
+ .insert(platform_integrations)
+ .values({
+ owned_by_organization_id: organizationId,
+ platform: 'gitlab',
+ integration_type: 'oauth',
+ integration_status: 'active',
+ platform_installation_id: `inst-${crypto.randomUUID()}`,
+ metadata: { ...metadata, webhook_secret: oldSecret },
+ })
+ .returning();
+ return { id: integration!.id, secret: oldSecret };
+}
+
+async function readMetadata(organizationId: string) {
+ const row = await db.query.platform_integrations.findFirst({
+ where: and(
+ eq(platform_integrations.owned_by_organization_id, organizationId),
+ eq(platform_integrations.platform, 'gitlab')
+ ),
+ });
+ return (row?.metadata ?? {}) as Record;
+}
+
+async function readWebhookSecret(organizationId: string): Promise {
+ const md = await readMetadata(organizationId);
+ return md.webhook_secret as string | undefined;
+}
+
+async function settingsChangeAuditMessages(organizationId: string): Promise {
+ const rows = await db
+ .select({ message: organization_audit_logs.message })
+ .from(organization_audit_logs)
+ .where(
+ and(
+ eq(organization_audit_logs.organization_id, organizationId),
+ eq(organization_audit_logs.action, 'organization.settings.change')
+ )
+ );
+ return rows.map(r => r.message);
+}
+
+describe('P1-D-32 GitLab webhook secret (rotation + status)', () => {
+ afterAll(async () => {
+ for (const organizationId of CREATED_ORG_IDS) {
+ await db
+ .delete(organization_audit_logs)
+ .where(eq(organization_audit_logs.organization_id, organizationId));
+ await db
+ .delete(platform_integrations)
+ .where(eq(platform_integrations.owned_by_organization_id, organizationId));
+ await db
+ .delete(organization_memberships)
+ .where(eq(organization_memberships.organization_id, organizationId));
+ await db.delete(organizations).where(eq(organizations.id, organizationId));
+ }
+ if (SEED_USER_IDS.length > 0) {
+ await db.delete(kilocode_users).where(inArray(kilocode_users.id, SEED_USER_IDS));
+ }
+ });
+
+ beforeEach(() => {
+ mockSyncWebhooksForRepositories.mockReset();
+ mockGetValidGitLabToken.mockReset();
+ // Default sync outcome: every currently-configured repo was "updated"
+ // (mirrors the `previous=[]` "treat all as added" path used by rotate).
+ mockSyncWebhooksForRepositories.mockImplementation(
+ async (_token, _secret, selectedIds, _previous, configuredWebhooks) => {
+ const updatedWebhooks: Record<
+ string,
+ { hook_id: number; created_at: string; updated_at?: string }
+ > = {};
+ for (const id of selectedIds) {
+ const existing = configuredWebhooks[String(id)];
+ updatedWebhooks[String(id)] = {
+ hook_id: existing?.hook_id ?? 1000 + Number(id),
+ created_at: existing?.created_at ?? new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ }
+ return {
+ result: {
+ created: [],
+ updated: selectedIds.map((id: number) => ({ projectId: id, hookId: 1000 + id })),
+ deleted: [],
+ errors: [],
+ },
+ updatedWebhooks,
+ };
+ }
+ );
+ mockGetValidGitLabToken.mockResolvedValue('gitlab-access-token');
+ });
+
+ describe('organization review agent router: getGitLabStatus P1-D-32 (omits webhook secret)', () => {
+ it('returns the integration shape WITHOUT webhookSecret for a non-privileged member', async () => {
+ const { organization } = await makeOrgAndOwner();
+ const member = await insertTestUser();
+ SEED_USER_IDS.push(member.id);
+ await addUserToOrganization(organization.id, member.id, 'member');
+
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: { '101': { hook_id: 9001, created_at: '2026-01-01T00:00:00Z' } },
+ });
+
+ const caller = await createCallerForUser(member.id);
+ const status = await caller.organizations.reviewAgent.getGitLabStatus({
+ organizationId: organization.id,
+ });
+
+ expect(status.connected).toBe(true);
+ expect(status.integration).toBeDefined();
+ // Regression guard: the secret must NEVER appear in the status
+ // payload. If this assertion fails, the leak has been re-introduced.
+ expect(status.integration).not.toHaveProperty('webhookSecret');
+ expect((status.integration as Record).webhookSecret).toBeUndefined();
+ // The rest of the shape is preserved (the non-secret fields still ship;
+ // account/repositorySelection/installedAt are passed through verbatim from
+ // the stored integration row regardless of their concrete values).
+ expect(status.integration).toEqual(
+ expect.objectContaining({
+ isValid: true,
+ instanceUrl: 'https://gitlab.example.com',
+ })
+ );
+ expect(status.integration).toHaveProperty('accountLogin');
+ expect(status.integration).toHaveProperty('repositorySelection');
+ expect(status.integration).toHaveProperty('installedAt');
+ });
+
+ it('still omits webhookSecret when the caller is the owner', async () => {
+ const { owner, organization } = await makeOrgAndOwner();
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: {},
+ });
+
+ const caller = await createCallerForUser(owner.id);
+ const status = await caller.organizations.reviewAgent.getGitLabStatus({
+ organizationId: organization.id,
+ });
+
+ expect(status.connected).toBe(true);
+ expect(status.integration).not.toHaveProperty('webhookSecret');
+ });
+ });
+
+ describe('organization review agent router: rotateGitLabWebhookSecret P1-D-32', () => {
+ it('is denied for a plain org member (UNAUTHORIZED)', async () => {
+ const { organization } = await makeOrgAndOwner();
+ const member = await insertTestUser();
+ SEED_USER_IDS.push(member.id);
+ await addUserToOrganization(organization.id, member.id, 'member');
+ await seedGitLabIntegration(organization.id, { configured_webhooks: {} });
+
+ const caller = await createCallerForUser(member.id);
+ await expect(
+ caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
+ });
+
+ it('is denied for a non-member (UNAUTHORIZED)', async () => {
+ const { organization } = await makeOrgAndOwner();
+ // No membership row for this user at all — should be FORBIDDEN by
+ // the billing-mutation procedure before the handler runs.
+ const stranger = await insertTestUser();
+ SEED_USER_IDS.push(stranger.id);
+ await seedGitLabIntegration(organization.id, { configured_webhooks: {} });
+
+ const caller = await createCallerForUser(stranger.id);
+ await expect(
+ caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
+ });
+
+ it('is allowed for the owner, persists a NEW secret, re-syncs webhooks, and returns the new secret once', async () => {
+ const { owner, organization } = await makeOrgAndOwner();
+ const configured = {
+ '101': { hook_id: 9001, created_at: '2026-01-01T00:00:00Z' },
+ '202': { hook_id: 9002, created_at: '2026-01-02T00:00:00Z' },
+ };
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+
+ const caller = await createCallerForUser(owner.id);
+ const result = await caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ });
+
+ // Returned secret must be a non-empty hex string and distinct from
+ // the previously stored one. The new secret is returned ONCE here.
+ expect(typeof result.webhookSecret).toBe('string');
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSecret).not.toBe('old-secret-do-not-leak');
+
+ // Re-sync must be invoked exactly once, with the new secret, the
+ // configured repo ids (as numbers), previous=[] (so every currently
+ // configured repo is treated as "added" and UPDATED in place), the
+ // existing configured_webhooks map, and the stored instance URL.
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledTimes(1);
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith(
+ 'gitlab-access-token',
+ result.webhookSecret,
+ [101, 202],
+ [],
+ configured,
+ 'https://gitlab.example.com'
+ );
+ expect(result.webhookSync.updated).toBe(2);
+ expect(result.webhookSync.created).toBe(0);
+ expect(result.webhookSync.deleted).toBe(0);
+ expect(result.webhookSync.errors).toEqual([]);
+ expect(result.configuredWebhookCount).toBe(2);
+
+ // Persistence: metadata.webhook_secret is the NEW secret and
+ // metadata.configured_webhooks was updated with the sync output.
+ const storedSecret = await readWebhookSecret(organization.id);
+ expect(storedSecret).toBe(result.webhookSecret);
+
+ const storedMetadata = await readMetadata(organization.id);
+ const storedConfigured = storedMetadata.configured_webhooks as Record<
+ string,
+ { hook_id: number; created_at: string; updated_at?: string }
+ >;
+ expect(Object.keys(storedConfigured).sort()).toEqual(['101', '202']);
+ expect(storedConfigured['101']?.updated_at).toBeDefined();
+ expect(storedConfigured['202']?.updated_at).toBeDefined();
+
+ // The new secret is NEVER logged/returned elsewhere — the only
+ // exposure point is the one-shot return value above. Audit log
+ // records the rotation event WITHOUT the secret.
+ const auditMessages = await settingsChangeAuditMessages(organization.id);
+ const rotateMessages = auditMessages.filter(m =>
+ m.startsWith('Rotated GitLab webhook secret')
+ );
+ expect(rotateMessages).toHaveLength(1);
+ expect(rotateMessages[0]).not.toContain(result.webhookSecret);
+ expect(rotateMessages[0]).toContain('2 updated');
+ });
+
+ it('is allowed for a billing_manager, with the same re-sync behavior', async () => {
+ const { organization } = await makeOrgAndOwner();
+ const billingManager = await insertTestUser();
+ SEED_USER_IDS.push(billingManager.id);
+ await addUserToOrganization(organization.id, billingManager.id, 'billing_manager');
+ const configured = {
+ '303': { hook_id: 9030, created_at: '2026-02-01T00:00:00Z' },
+ };
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: configured,
+ });
+
+ const caller = await createCallerForUser(billingManager.id);
+ const result = await caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ });
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith(
+ 'gitlab-access-token',
+ result.webhookSecret,
+ [303],
+ [],
+ configured,
+ 'https://gitlab.com'
+ );
+ expect(await readWebhookSecret(organization.id)).toBe(result.webhookSecret);
+ });
+
+ it('with empty configured_webhooks returns the new secret and does NOT call sync', async () => {
+ const { owner, organization } = await makeOrgAndOwner();
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: {},
+ });
+
+ const caller = await createCallerForUser(owner.id);
+ const result = await caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ });
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSync).toEqual({
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [],
+ });
+ expect(result.configuredWebhookCount).toBe(0);
+ expect(mockSyncWebhooksForRepositories).not.toHaveBeenCalled();
+ // No token lookup needed when there are no webhooks to re-sync.
+ expect(mockGetValidGitLabToken).not.toHaveBeenCalled();
+ // The new secret was still persisted for manual reconfiguration.
+ expect(await readWebhookSecret(organization.id)).toBe(result.webhookSecret);
+
+ // Audit log mentions the no-rotation branch so operators can tell
+ // manual-only rotations apart from synced rotations.
+ const auditMessages = await settingsChangeAuditMessages(organization.id);
+ expect(auditMessages).toEqual([
+ 'Rotated GitLab webhook secret (no Kilo-managed webhooks to re-sync)',
+ ]);
+ });
+
+ it('only touches THIS integration — does not rotate another orgs secret', async () => {
+ const { owner: ownerA, organization: orgA } = await makeOrgAndOwner();
+ const { organization: orgB } = await makeOrgAndOwner();
+
+ const configuredA = { '1': { hook_id: 1, created_at: '2026-01-01T00:00:00Z' } };
+ const configuredB = { '2': { hook_id: 2, created_at: '2026-01-01T00:00:00Z' } };
+ await seedGitLabIntegration(orgA.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: configuredA,
+ });
+ await seedGitLabIntegration(orgB.id, {
+ gitlab_instance_url: 'https://gitlab.com',
+ configured_webhooks: configuredB,
+ });
+
+ const oldSecretB = await readWebhookSecret(orgB.id);
+ expect(oldSecretB).toBe('old-secret-do-not-leak');
+
+ const callerA = await createCallerForUser(ownerA.id);
+ const result = await callerA.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: orgA.id,
+ });
+
+ // Org A was rotated; org B is untouched.
+ expect(await readWebhookSecret(orgA.id)).toBe(result.webhookSecret);
+ expect(await readWebhookSecret(orgB.id)).toBe('old-secret-do-not-leak');
+
+ // Sync was called only once, for org A's configured repo id.
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledTimes(1);
+ expect(mockSyncWebhooksForRepositories).toHaveBeenCalledWith(
+ 'gitlab-access-token',
+ result.webhookSecret,
+ [1],
+ [],
+ configuredA,
+ 'https://gitlab.com'
+ );
+ });
+ });
+});
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.ts
index 8d90927a85..e5f2ad7860 100644
--- a/apps/web/src/routers/organizations/organization-code-reviews-router.ts
+++ b/apps/web/src/routers/organizations/organization-code-reviews-router.ts
@@ -47,6 +47,7 @@ import {
getCodeReviewActionRequiredState,
} from '@/lib/code-reviews/action-required';
import { getReviewMemoryEnabledFromConfig } from '@/lib/code-reviews/review-memory/settings';
+import { randomBytes } from 'node:crypto';
import {
createManualCodeReviewJob,
ManualCodeReviewJobInputSchema,
@@ -480,9 +481,12 @@ export const organizationReviewAgentRouter = createTRPCRouter({
};
}
- // Extract webhook secret from metadata for display
+ // NOTE: The webhook secret is intentionally NOT returned here. The
+ // previous shape leaked it to every org member. The secret is now
+ // surfaced only via the billing-gated `rotateGitLabWebhookSecret`
+ // mutation (returned once, on demand) and the manual webhook setup
+ // instructions in the UI. See P1-D-32.
const metadata = integration.metadata as Record | null;
- const webhookSecret = metadata?.webhook_secret as string | undefined;
return {
connected: true,
@@ -491,7 +495,6 @@ export const organizationReviewAgentRouter = createTRPCRouter({
repositorySelection: integration.repository_access,
installedAt: integration.installed_at,
isValid: true,
- webhookSecret, // Include webhook secret for user to configure in GitLab
instanceUrl: (metadata?.gitlab_instance_url as string) || 'https://gitlab.com',
},
};
@@ -1232,4 +1235,156 @@ export const organizationReviewAgentRouter = createTRPCRouter({
});
}
}),
+
+ /**
+ * Rotate the GitLab webhook secret for the org integration and re-sync
+ * the Kilo-managed webhooks so they keep validating with the new secret.
+ *
+ * Admin-gated (owner or billing_manager) — the previous shape leaked the
+ * secret to every org member via `getGitLabStatus`. The new secret is
+ * returned ONCE, only to the caller, for manual reconfiguration. When
+ * Kilo-managed webhooks exist they are all UPDATED in place (the
+ * `previous = []` trick makes every currently-configured repo an
+ * "added" repo, which the sync helper handles as an update of the
+ * existing webhook). When no webhooks are configured (manual-only
+ * setup) the sync is skipped and the secret is still returned once.
+ *
+ * Scope is per-org: only this integration's metadata is touched. See
+ * P1-D-32.
+ */
+ rotateGitLabWebhookSecret: organizationBillingMutationProcedure
+ .input(OrganizationIdInputSchema)
+ .mutation(async ({ input, ctx }) => {
+ const organizationId = input.organizationId;
+ const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB);
+
+ if (!integration) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'GitLab integration not found for this organization',
+ });
+ }
+
+ const existingMetadata = (integration.metadata || {}) as Record;
+ const configuredWebhooks =
+ (existingMetadata.configured_webhooks as Record | undefined) ??
+ {};
+ const instanceUrl =
+ (existingMetadata.gitlab_instance_url as string | undefined) || 'https://gitlab.com';
+
+ // Persist a brand-new secret. We generate it here (rather than via
+ // gitlab-service.regenerateWebhookSecret) so the re-sync and the
+ // metadata write happen against a single secret in a single
+ // operation. Never log the secret or the full metadata.
+ const newSecret = randomBytes(32).toString('hex');
+
+ // If no Kilo-managed webhooks are configured, skip the network
+ // round-trip entirely and just persist + return the new secret.
+ if (Object.keys(configuredWebhooks).length === 0) {
+ await updateIntegrationMetadata(integration.id, {
+ ...existingMetadata,
+ webhook_secret: newSecret,
+ });
+ await createAuditLog({
+ organization_id: organizationId,
+ action: 'organization.settings.change',
+ actor_id: ctx.user.id,
+ actor_email: ctx.user.google_user_email,
+ actor_name: ctx.user.google_user_name,
+ message: 'Rotated GitLab webhook secret (no Kilo-managed webhooks to re-sync)',
+ });
+ return {
+ webhookSecret: newSecret,
+ webhookSync: {
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [] as Array<{ projectId: number; error: string; operation: string }>,
+ },
+ configuredWebhookCount: 0,
+ };
+ }
+
+ let webhookSyncResult: {
+ created: number;
+ updated: number;
+ deleted: number;
+ errors: Array<{ projectId: number; error: string; operation: string }>;
+ } = { created: 0, updated: 0, deleted: 0, errors: [] };
+ let updatedWebhooks: Record = configuredWebhooks;
+
+ try {
+ const accessToken = await getValidGitLabToken(integration, {
+ userId: ctx.user.id,
+ organizationId,
+ });
+ const configuredRepoIds = Object.keys(configuredWebhooks)
+ .map(id => Number.parseInt(id, 10))
+ .filter(id => Number.isFinite(id));
+
+ // Pass `previous = []` so the sync helper treats every currently
+ // configured repo as newly added and UPDATES its existing Kilo
+ // webhook in place with the new secret — nothing is deleted.
+ const syncOutcome = await syncWebhooksForRepositories(
+ accessToken,
+ newSecret,
+ configuredRepoIds,
+ [],
+ configuredWebhooks,
+ instanceUrl
+ );
+ updatedWebhooks = syncOutcome.updatedWebhooks;
+ webhookSyncResult = {
+ created: syncOutcome.result.created.length,
+ updated: syncOutcome.result.updated.length,
+ deleted: syncOutcome.result.deleted.length,
+ errors: syncOutcome.result.errors,
+ };
+ logExceptInTest('[rotateGitLabWebhookSecret] Webhook re-sync completed for organization', {
+ created: webhookSyncResult.created,
+ updated: webhookSyncResult.updated,
+ deleted: webhookSyncResult.deleted,
+ errorCount: webhookSyncResult.errors.length,
+ });
+ } catch (webhookError) {
+ // Re-sync failure MUST NOT lose the new secret: persist it
+ // anyway so the operator can recover via manual reconfiguration.
+ logExceptInTest('[rotateGitLabWebhookSecret] Webhook re-sync failed for organization', {
+ error: webhookError instanceof Error ? webhookError.message : String(webhookError),
+ });
+ webhookSyncResult = {
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ errors: [
+ {
+ projectId: 0,
+ error: webhookError instanceof Error ? webhookError.message : 'Unknown error',
+ operation: 'sync' as const,
+ },
+ ],
+ };
+ }
+
+ await updateIntegrationMetadata(integration.id, {
+ ...existingMetadata,
+ webhook_secret: newSecret,
+ configured_webhooks: updatedWebhooks,
+ });
+
+ await createAuditLog({
+ organization_id: organizationId,
+ action: 'organization.settings.change',
+ actor_id: ctx.user.id,
+ actor_email: ctx.user.google_user_email,
+ actor_name: ctx.user.google_user_name,
+ message: `Rotated GitLab webhook secret (webhooks: ${webhookSyncResult.updated} updated, ${webhookSyncResult.errors.length} errors)`,
+ });
+
+ return {
+ webhookSecret: newSecret,
+ webhookSync: webhookSyncResult,
+ configuredWebhookCount: Object.keys(updatedWebhooks).length,
+ };
+ }),
});
From 1caf10cafc21aa0c8f41b44e98b42a3ec2090783 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 07:58:56 +0200
Subject: [PATCH 12/19] test(web): cover GitLab webhook rotation re-sync
failure path
---
.../src/routers/code-reviews-router.test.ts | 43 ++++++++++++
...ization-code-reviews-gitlab-rotate.test.ts | 65 +++++++++++++++++++
2 files changed, 108 insertions(+)
diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts
index 894bd92cec..0ee00fe2cd 100644
--- a/apps/web/src/routers/code-reviews-router.test.ts
+++ b/apps/web/src/routers/code-reviews-router.test.ts
@@ -2819,4 +2819,47 @@ describe('gitlab.regenerateWebhookSecret P1-D-32 (self-only, re-syncs)', () => {
'https://gitlab.com'
);
});
+
+ it('persists the NEW secret even when the webhook re-sync throws (no lost-secret state)', async () => {
+ const configured = {
+ '404': { hook_id: 9040, created_at: '2026-03-01T00:00:00Z' },
+ };
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+ mockSyncWebhooksForRepositories.mockRejectedValueOnce(new Error('gitlab responded 500'));
+
+ const caller = await createCallerForUser(testUser.id);
+ // Must not throw: losing the just-rotated secret while GitLab may
+ // already carry it would strand the caller's integration.
+ const result = await caller.gitlab.regenerateWebhookSecret();
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSecret).not.toBe('old-secret-do-not-leak');
+ expect(result.webhookSync.errors).toHaveLength(1);
+ expect(result.webhookSync.updated).toBe(0);
+ // New secret persisted for manual recovery; surfaced error omits it.
+ expect(await readPersonalWebhookSecret(testUser.id)).toBe(result.webhookSecret);
+ expect(JSON.stringify(result.webhookSync.errors)).not.toContain(result.webhookSecret);
+ });
+
+ it('persists the NEW secret even when the access-token lookup throws', async () => {
+ const configured = {
+ '505': { hook_id: 9050, created_at: '2026-03-02T00:00:00Z' },
+ };
+ await seedPersonalGitLabIntegration(testUser.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+ mockGetValidGitLabToken.mockRejectedValueOnce(new Error('token expired'));
+
+ const caller = await createCallerForUser(testUser.id);
+ const result = await caller.gitlab.regenerateWebhookSecret();
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSync.errors).toHaveLength(1);
+ expect(mockSyncWebhooksForRepositories).not.toHaveBeenCalled();
+ expect(await readPersonalWebhookSecret(testUser.id)).toBe(result.webhookSecret);
+ });
});
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts b/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
index 4e1aad60ec..72d8583d9f 100644
--- a/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
+++ b/apps/web/src/routers/organizations/organization-code-reviews-gitlab-rotate.test.ts
@@ -402,5 +402,70 @@ describe('P1-D-32 GitLab webhook secret (rotation + status)', () => {
'https://gitlab.com'
);
});
+
+ it('persists the NEW secret even when the webhook re-sync throws (no lost-secret state)', async () => {
+ const { owner, organization } = await makeOrgAndOwner();
+ const configured = {
+ '404': { hook_id: 9040, created_at: '2026-03-01T00:00:00Z' },
+ };
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+ // The GitLab re-sync call fails outright (e.g. a 5xx from GitLab).
+ const syncError = new Error('gitlab responded 500');
+ mockSyncWebhooksForRepositories.mockRejectedValueOnce(syncError);
+
+ const caller = await createCallerForUser(owner.id);
+ // The mutation MUST NOT throw — losing the just-rotated secret while
+ // GitLab may already carry it would strand the integration.
+ const result = await caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ });
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSecret).not.toBe('old-secret-do-not-leak');
+ // The failure is surfaced as a sync error, not swallowed silently.
+ expect(result.webhookSync.errors).toHaveLength(1);
+ expect(result.webhookSync.updated).toBe(0);
+
+ // The new secret is persisted regardless, so the operator can recover
+ // via manual reconfiguration and a retry does not desync further.
+ expect(await readWebhookSecret(organization.id)).toBe(result.webhookSecret);
+
+ // Neither the surfaced error payload nor the audit log leaks the secret.
+ expect(JSON.stringify(result.webhookSync.errors)).not.toContain(result.webhookSecret);
+ const auditMessages = await settingsChangeAuditMessages(organization.id);
+ const rotateMessages = auditMessages.filter(m =>
+ m.startsWith('Rotated GitLab webhook secret')
+ );
+ expect(rotateMessages).toHaveLength(1);
+ expect(rotateMessages[0]).not.toContain(result.webhookSecret);
+ });
+
+ it('persists the NEW secret even when the access-token lookup throws', async () => {
+ const { owner, organization } = await makeOrgAndOwner();
+ const configured = {
+ '505': { hook_id: 9050, created_at: '2026-03-02T00:00:00Z' },
+ };
+ await seedGitLabIntegration(organization.id, {
+ gitlab_instance_url: 'https://gitlab.example.com',
+ configured_webhooks: configured,
+ });
+ // Token resolution fails before the sync can run.
+ mockGetValidGitLabToken.mockRejectedValueOnce(new Error('token expired'));
+
+ const caller = await createCallerForUser(owner.id);
+ const result = await caller.organizations.reviewAgent.rotateGitLabWebhookSecret({
+ organizationId: organization.id,
+ });
+
+ expect(result.webhookSecret).toMatch(/^[0-9a-f]{64}$/);
+ expect(result.webhookSync.errors).toHaveLength(1);
+ // Sync is never reached when the token lookup fails.
+ expect(mockSyncWebhooksForRepositories).not.toHaveBeenCalled();
+ // Secret still persisted for recovery.
+ expect(await readWebhookSecret(organization.id)).toBe(result.webhookSecret);
+ });
});
});
From ca3586d75d33076c58fea23fb6617a9081040dc0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 08:00:27 +0200
Subject: [PATCH 13/19] test(web): validate PR Review GraphQL documents against
GitHub schema
---
apps/web/package.json | 2 +
.../github-pr-review-graphql-schema.test.ts | 62 +++++++++++++++++++
.../src/routers/github-pr-review-router.ts | 16 +++++
pnpm-lock.yaml | 51 ++++++++++++---
4 files changed, 122 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/routers/github-pr-review-graphql-schema.test.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index 3d91180c77..1b38fdea0c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -188,8 +188,10 @@
"@types/react-dom": "19.2.3",
"@typescript/native-preview": "catalog:",
"babel-plugin-react-compiler": "1.0.0",
+ "@octokit/graphql-schema": "15.26.1",
"dependency-cruiser": "17.3.10",
"dotenv": "17.3.1",
+ "graphql": "16.14.2",
"ink": "6.8.0",
"jest": "30.3.0",
"knip": "5.86.0",
diff --git a/apps/web/src/routers/github-pr-review-graphql-schema.test.ts b/apps/web/src/routers/github-pr-review-graphql-schema.test.ts
new file mode 100644
index 0000000000..bb3b94c8b3
--- /dev/null
+++ b/apps/web/src/routers/github-pr-review-graphql-schema.test.ts
@@ -0,0 +1,62 @@
+// Schema-validity test for every raw PR-Review GraphQL document in
+// `github-pr-review-router.ts`. Pinned against GitHub's official GraphQL SDL
+// (bundled in `@octokit/graphql-schema`, auto-updated by that package) using
+// `graphql`'s `parse` + `validate` so a future GitHub schema change or a
+// hand-edited typo is caught at test time rather than at runtime against
+// `octokit.request('POST /graphql', …)`.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { buildClientSchema, parse, validate } from 'graphql';
+
+import { describe, test, expect } from '@jest/globals';
+
+import { PR_REVIEW_GRAPHQL_DOCUMENTS } from '@/routers/github-pr-review-router';
+
+// `@octokit/graphql-schema` is published as an ESM-only package (`"type":
+// "module"`), which Jest's CJS test runner cannot `import` directly without
+// enabling `--experimental-vm-modules`. We still depend on the package — it
+// auto-updates `schema.json` (GitHub's authoritative GraphQL introspection
+// result) and `schema.graphql` (the matching SDL) on every GitHub schema
+// change — and read the introspection JSON off disk so the dependency is
+// exercised. `buildClientSchema` is the recommended way to materialize a
+// schema from an introspection result and is what `@octokit/graphql-schema`'s
+// own `validate` helper uses internally; the SDL cannot be passed to
+// `buildSchema` directly because it contains extension types that the strict
+// SDL builder rejects. If the dependency is silently dropped, this read
+// fails and the test errors loudly.
+const introspectionPath = join(__dirname, '../../node_modules/@octokit/graphql-schema/schema.json');
+const introspection = JSON.parse(readFileSync(introspectionPath, 'utf8'));
+const githubSchema = buildClientSchema(introspection);
+
+describe('github-pr-review-router GraphQL documents', () => {
+ test('exports exactly 9 documents (sanity guard for the export record)', () => {
+ expect(Object.keys(PR_REVIEW_GRAPHQL_DOCUMENTS)).toHaveLength(9);
+ });
+
+ test.each(Object.entries(PR_REVIEW_GRAPHQL_DOCUMENTS))(
+ '%s is valid against the GitHub GraphQL schema',
+ (_name, doc) => {
+ const parsed = parse(doc);
+ const errors = validate(githubSchema, parsed);
+ expect(errors).toEqual([]);
+ }
+ );
+
+ test('validate() flags a deliberately broken document (teeth guard)', () => {
+ // Reference a field that does not exist on the GitHub `Repository` type
+ // (`definitelyNotAFieldOnRepository`). If validate() ever stops being
+ // strict, this test will start passing-on-bad-docs and the guard fails.
+ const broken = /* GraphQL */ `
+ query BrokenTeethGuard {
+ repository(owner: "x", name: "y") {
+ definitelyNotAFieldOnRepository
+ }
+ }
+ `;
+ const parsed = parse(broken);
+ const errors = validate(githubSchema, parsed);
+ expect(errors.length).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts
index c219f1c531..7d9e8650dc 100644
--- a/apps/web/src/routers/github-pr-review-router.ts
+++ b/apps/web/src/routers/github-pr-review-router.ts
@@ -401,6 +401,22 @@ function normalizeComment(node: GraphQlCommentNode) {
// Exported for unit testing the follow-up pagination loop.
export const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST = REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY;
+// All raw PR-Review GraphQL documents defined in this router, collected as a
+// single exported record so the schema-validity test enumerates docs from
+// module exports (newly added docs are auto-covered). Keys are the
+// operation name / mutation tag; values are the unchanged document strings.
+export const PR_REVIEW_GRAPHQL_DOCUMENTS = {
+ PULL_REQUEST_FRAGMENT_QUERY,
+ REVIEW_THREADS_QUERY,
+ REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY,
+ ENABLE_AUTO_MERGE_MUTATION,
+ DISABLE_AUTO_MERGE_MUTATION,
+ RESOLVE_THREAD_MUTATION,
+ UNRESOLVE_THREAD_MUTATION,
+ ADD_REACTION_MUTATION,
+ REMOVE_REACTION_MUTATION,
+} as const;
+
// Exported for unit testing the reaction DTO invariant pinned against
// GitHub's actual `reactionGroups` shape. The downstream DTO contract —
// `Array<{ content: string; count: number; viewerHasReacted: boolean }>` —
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 94de1a80ce..fa17614807 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -639,7 +639,7 @@ importers:
version: 4.27.0
'@chat-adapter/linear':
specifier: 4.27.0
- version: 4.27.0
+ version: 4.27.0(graphql@16.14.2)
'@chat-adapter/slack':
specifier: 4.27.0
version: 4.27.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
@@ -699,7 +699,7 @@ importers:
version: link:../../packages/worker-utils
'@linear/sdk':
specifier: 76.0.0
- version: 76.0.0
+ version: 76.0.0(graphql@16.14.2)
'@lottiefiles/dotlottie-react':
specifier: 0.17.15
version: 0.17.15(react@19.2.6)
@@ -1043,6 +1043,9 @@ importers:
'@jest/globals':
specifier: 30.3.0
version: 30.3.0
+ '@octokit/graphql-schema':
+ specifier: 15.26.1
+ version: 15.26.1
'@playwright/test':
specifier: 1.58.2
version: 1.58.2
@@ -1085,6 +1088,9 @@ importers:
dotenv:
specifier: 17.3.1
version: 17.3.1
+ graphql:
+ specifier: 16.14.2
+ version: 16.14.2
ink:
specifier: 6.8.0
version: 6.8.0(@types/react@19.2.14)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6)
@@ -4477,11 +4483,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
- deprecated: 'Merged into tsx: https://tsx.hirok.io'
+ deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
- deprecated: 'Merged into tsx: https://tsx.hirok.io'
+ deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
@@ -5774,6 +5780,9 @@ packages:
resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==}
engines: {node: '>= 20'}
+ '@octokit/graphql-schema@15.26.1':
+ resolution: {integrity: sha512-RFDC2MpRBd4AxSRvUeBIVeBU7ojN/SxDfALUd7iVYOSeEK3gZaqR2MGOysj4Zh2xj2RY5fQAUT+Oqq7hWTraMA==}
+
'@octokit/graphql@9.0.3':
resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==}
engines: {node: '>= 20'}
@@ -12839,6 +12848,16 @@ packages:
resolution: {integrity: sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==}
engines: {node: ^12.20.0 || >=14.13.1}
+ graphql-tag@2.12.7:
+ resolution: {integrity: sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ graphql@16.14.2:
+ resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
growly@1.3.0:
resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
@@ -19592,10 +19611,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@chat-adapter/linear@4.27.0':
+ '@chat-adapter/linear@4.27.0(graphql@16.14.2)':
dependencies:
'@chat-adapter/shared': 4.27.0
- '@linear/sdk': 76.0.0
+ '@linear/sdk': 76.0.0(graphql@16.14.2)
chat: 4.27.0
transitivePeerDependencies:
- graphql
@@ -20796,7 +20815,9 @@ snapshots:
'@grammyjs/types@3.27.3': {}
- '@graphql-typed-document-node/core@3.2.0': {}
+ '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)':
+ dependencies:
+ graphql: 16.14.2
'@hapi/hoek@9.3.0': {}
@@ -21605,9 +21626,9 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
- '@linear/sdk@76.0.0':
+ '@linear/sdk@76.0.0(graphql@16.14.2)':
dependencies:
- '@graphql-typed-document-node/core': 3.2.0
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
transitivePeerDependencies:
- graphql
@@ -22020,6 +22041,11 @@ snapshots:
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
+ '@octokit/graphql-schema@15.26.1':
+ dependencies:
+ graphql: 16.14.2
+ graphql-tag: 2.12.7(graphql@16.14.2)
+
'@octokit/graphql@9.0.3':
dependencies:
'@octokit/request': 10.0.8
@@ -29659,6 +29685,13 @@ snapshots:
- encoding
- supports-color
+ graphql-tag@2.12.7(graphql@16.14.2):
+ dependencies:
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ graphql@16.14.2: {}
+
growly@1.3.0: {}
gzip-size@6.0.0:
From ac6a76216dbe0a2a045d8cb93a88b2521c8207f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Thu, 23 Jul 2026 12:50:09 +0200
Subject: [PATCH 14/19] fix: address Kilobot review comments on PR #4696
- use-code-reviews.ts: only throw when createManualReviewJob explicitly
returns {success: false}; the real success payload has no success field
so the previous check treated every successful creation as a failure.
- mention-command.ts: tighten MENTION_PATTERN so it matches @kilo,
@kilocode, and @kilocode-bot but rejects unrelated @kilo-prefixed
handles such as @kilocorp and @kilogram.
- Update unit tests for both fixes.
---
apps/mobile/src/lib/hooks/use-code-reviews.test.ts | 4 ++--
apps/mobile/src/lib/hooks/use-code-reviews.ts | 11 ++++++-----
.../src/code-review/mention-command.test.ts | 6 ++++++
.../app-shared/src/code-review/mention-command.ts | 8 +++++++-
4 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/apps/mobile/src/lib/hooks/use-code-reviews.test.ts b/apps/mobile/src/lib/hooks/use-code-reviews.test.ts
index b628ad3094..8c244440c3 100644
--- a/apps/mobile/src/lib/hooks/use-code-reviews.test.ts
+++ b/apps/mobile/src/lib/hooks/use-code-reviews.test.ts
@@ -253,7 +253,7 @@ describe('createManualReviewMutationFn', () => {
});
it('resolves with the full success payload (including reviewId) so caller navigation works', async () => {
- const successPayload = { success: true, reviewId: 'rev_abc123' };
+ const successPayload = { reviewId: 'rev_abc123', outputMode: 'provider' };
personalCreateMutateMock.mockResolvedValue(successPayload);
await expect(createManualReviewMutationFn('personal', CREATE_VARS)).resolves.toEqual(
@@ -285,7 +285,7 @@ describe('useCreateManualReview wiring', () => {
it('invalidates the list (no detail) on real success', () => {
const opts = getOptions('create', 'personal');
- opts.onSuccess?.({ success: true, reviewId: 'rev_abc123' }, undefined);
+ opts.onSuccess?.({ reviewId: 'rev_abc123', outputMode: 'provider' }, undefined);
expect(invalidateQueriesMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
diff --git a/apps/mobile/src/lib/hooks/use-code-reviews.ts b/apps/mobile/src/lib/hooks/use-code-reviews.ts
index 5c9a3d2cb8..15cc60819b 100644
--- a/apps/mobile/src/lib/hooks/use-code-reviews.ts
+++ b/apps/mobile/src/lib/hooks/use-code-reviews.ts
@@ -137,11 +137,12 @@ export async function createManualReviewMutationFn(scope: string, vars: CreateMa
organizationId: scope,
});
// The create router resolves with the job result directly (no
- // `{success, error}` envelope) or throws — this check is defensive
- // against the `{success: false}` shape other code-reviews mutations
- // use, so a domain failure here still routes to onError.
- if (!(result as { success?: boolean }).success) {
- throw new Error((result as { error?: string }).error);
+ // `{success, error}` envelope) or throws. Keep a narrow defensive guard
+ // in case the mutation ever returns the `{success: false, error}` shape
+ // used by other code-reviews mutations, so a domain failure still routes
+ // to onError without treating a real success payload as a failure.
+ if ((result as { success?: boolean }).success === false) {
+ throw new Error((result as { error?: string }).error ?? 'Unknown error');
}
return result;
}
diff --git a/packages/app-shared/src/code-review/mention-command.test.ts b/packages/app-shared/src/code-review/mention-command.test.ts
index d1979afae9..b700d5e028 100644
--- a/packages/app-shared/src/code-review/mention-command.test.ts
+++ b/packages/app-shared/src/code-review/mention-command.test.ts
@@ -54,5 +54,11 @@ describe('parseFixCommand', () => {
expect(parseFixCommand('Looks good to me!')).toBe(false);
expect(parseFixCommand('LGTM, merging.')).toBe(false);
});
+
+ it('rejects unrelated @kilo-prefixed mentions that are not Kilo handles', () => {
+ expect(parseFixCommand('@kilocorp fix it')).toBe(false);
+ expect(parseFixCommand('@kilogram patch this')).toBe(false);
+ expect(parseFixCommand('@kilobyte fix')).toBe(false);
+ });
});
});
diff --git a/packages/app-shared/src/code-review/mention-command.ts b/packages/app-shared/src/code-review/mention-command.ts
index ca11019e90..2fcd2199da 100644
--- a/packages/app-shared/src/code-review/mention-command.ts
+++ b/packages/app-shared/src/code-review/mention-command.ts
@@ -23,7 +23,13 @@
* rejected so unrelated comment text does not trigger Auto Fix.
*/
-const MENTION_PATTERN = /@kilo[\w-]*/i;
+/**
+ * The mention pattern admits the known Kilo handles — @kilo, @kilocode,
+ * and @kilocode-bot — without matching unrelated tokens that start with the
+ * "kilo" prefix (e.g. @kilocorp, @kilogram). The first alternative matches a
+ * standalone @kilo; the second matches @kilocode with an optional suffix.
+ */
+const MENTION_PATTERN = /@kilo(?:\b|code[\w-]*\b)/i;
const FIX_KEYWORD_PATTERN = /\b(?:fix|patch)\b/i;
export function parseFixCommand(text: string): boolean {
From 79ef66c52da417b6ac5bf237c10421bce33792e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Mon, 27 Jul 2026 14:56:42 +0200
Subject: [PATCH 15/19] test(web): cover CONVERSATION_COMMENTS_QUERY in GraphQL
schema guard
The main merge added a tenth PR-Review document; enumerate it in
PR_REVIEW_GRAPHQL_DOCUMENTS so the schema-validity test keeps its
auto-coverage invariant, and bump the export-count guard 9 -> 10.
---
apps/web/src/routers/github-pr-review-graphql-schema.test.ts | 4 ++--
apps/web/src/routers/github-pr-review-router.ts | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/routers/github-pr-review-graphql-schema.test.ts b/apps/web/src/routers/github-pr-review-graphql-schema.test.ts
index bb3b94c8b3..2337282bbe 100644
--- a/apps/web/src/routers/github-pr-review-graphql-schema.test.ts
+++ b/apps/web/src/routers/github-pr-review-graphql-schema.test.ts
@@ -31,8 +31,8 @@ const introspection = JSON.parse(readFileSync(introspectionPath, 'utf8'));
const githubSchema = buildClientSchema(introspection);
describe('github-pr-review-router GraphQL documents', () => {
- test('exports exactly 9 documents (sanity guard for the export record)', () => {
- expect(Object.keys(PR_REVIEW_GRAPHQL_DOCUMENTS)).toHaveLength(9);
+ test('exports exactly 10 documents (sanity guard for the export record)', () => {
+ expect(Object.keys(PR_REVIEW_GRAPHQL_DOCUMENTS)).toHaveLength(10);
});
test.each(Object.entries(PR_REVIEW_GRAPHQL_DOCUMENTS))(
diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts
index 106d4fe9ad..635f632d53 100644
--- a/apps/web/src/routers/github-pr-review-router.ts
+++ b/apps/web/src/routers/github-pr-review-router.ts
@@ -472,6 +472,7 @@ export const PR_REVIEW_GRAPHQL_DOCUMENTS = {
PULL_REQUEST_FRAGMENT_QUERY,
REVIEW_THREADS_QUERY,
REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY,
+ CONVERSATION_COMMENTS_QUERY,
ENABLE_AUTO_MERGE_MUTATION,
DISABLE_AUTO_MERGE_MUTATION,
RESOLVE_THREAD_MUTATION,
From bcf6c0ff2c641909ee92ea261bdcf2fbba889324 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Mon, 27 Jul 2026 15:45:08 +0200
Subject: [PATCH 16/19] docs(mobile): record PR-review E2E env traps in
workflow learnings
The merge-resolution verifier hit three reusable setup blockers: missing
USER_GITHUB_APP_TOKEN_* keys in the worktree env, empty git-token-service
.dev.vars token keys, and the iOS paste / Safari open prompts. Record
symptom, cause, and fix for the next run.
---
apps/mobile/.kilo/WORKFLOW_LEARNINGS.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md b/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md
index 9f1ec49fd2..5c63d3ee60 100644
--- a/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md
+++ b/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md
@@ -5,3 +5,21 @@ Environment blockers and their fixes, recorded by the planner or orchestrator fo
## Planner
## Orchestrator
+
+### PR-review E2E env traps (stub token path)
+
+- **Symptom:** `githubApps.devSeedUserGithubToken` fails or the app shows
+ `GitHub connection expired` even after seeding; the GitHub stub never logs
+ a request.
+- **Cause:** (1) worktree `.env.local` missing the `USER_GITHUB_APP_TOKEN_*`
+ encryption keys, so the seeded token cannot be encrypted/decrypted; (2)
+ `services/git-token-service/.dev.vars` has empty token keys, so the token
+ endpoint returns 503.
+- **Fix:** copy the missing `USER_GITHUB_APP_TOKEN_*` key lines from the
+ primary checkout's `.env.local` (temporary, strip after the run), fill the
+ empty keys in `services/git-token-service/.dev.vars`, restart
+ git-token-service + nextjs, re-seed, then reopen the PR in the app (a
+ "Check connection" retry alone may not refetch after the first 412s).
+- **Also:** iOS shows an `Allow Paste` prompt before the PR-URL paste lands;
+ and the Safari `Open this page in "Kilo"?` wording can differ from the
+ settle-app regex — tap the exact `Open` accessibility action instead.
From 9c3097342ae435d56238560d922389a8c4dbce14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Mon, 27 Jul 2026 15:50:59 +0200
Subject: [PATCH 17/19] style(web): format resolved PR-review files with oxfmt
Prettier and oxfmt disagree on line layout; the repository format gate
uses oxfmt. Reformat the three conflict-resolved files so the root
format-check passes. No semantic change; 137 web tests + typecheck green.
---
.../normalize-reactions.test.ts | 72 +-
.../review-thread-comments.test.ts | 91 +-
.../src/routers/github-pr-review-router.ts | 1031 ++++++++---------
3 files changed, 562 insertions(+), 632 deletions(-)
diff --git a/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts b/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
index 5e1b601edb..a366ce4563 100644
--- a/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
+++ b/apps/web/src/lib/github-pr-review/normalize-reactions.test.ts
@@ -15,65 +15,63 @@
import {
normalizeComment_FOR_TEST,
normalizeReactions_FOR_TEST,
-} from "@/routers/github-pr-review-router";
+} from '@/routers/github-pr-review-router';
-describe("normalizeReactions (reactionGroups shape)", () => {
- it("maps a group with reactors.totalCount to { content, count, viewerHasReacted }", () => {
+describe('normalizeReactions (reactionGroups shape)', () => {
+ it('maps a group with reactors.totalCount to { content, count, viewerHasReacted }', () => {
const out = normalizeReactions_FOR_TEST([
{
- content: "+1",
+ content: '+1',
viewerHasReacted: true,
reactors: { totalCount: 3 },
},
]);
- expect(out).toEqual([{ content: "+1", count: 3, viewerHasReacted: true }]);
+ expect(out).toEqual([{ content: '+1', count: 3, viewerHasReacted: true }]);
});
- it("treats absent/null reactors as count: 0 — filtered out, never throws", () => {
+ it('treats absent/null reactors as count: 0 — filtered out, never throws', () => {
expect(
normalizeReactions_FOR_TEST([
- { content: "THUMBS_UP", viewerHasReacted: false, reactors: null },
- ]),
+ { content: 'THUMBS_UP', viewerHasReacted: false, reactors: null },
+ ])
).toEqual([]);
// `reactors` omitted entirely — same behavior.
- expect(
- normalizeReactions_FOR_TEST([
- { content: "HEART", viewerHasReacted: false },
- ]),
- ).toEqual([]);
+ expect(normalizeReactions_FOR_TEST([{ content: 'HEART', viewerHasReacted: false }])).toEqual(
+ []
+ );
});
- it("preserves source order of surviving entries and drops zero-count groups", () => {
+ it('preserves source order of surviving entries and drops zero-count groups', () => {
const out = normalizeReactions_FOR_TEST([
- { content: "+1", viewerHasReacted: true, reactors: { totalCount: 2 } },
+ { content: '+1', viewerHasReacted: true, reactors: { totalCount: 2 } },
{
- content: "LAUGH",
+ content: 'LAUGH',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "HEART",
+ content: 'HEART',
viewerHasReacted: false,
reactors: { totalCount: 7 },
},
]);
expect(out).toEqual([
- { content: "+1", count: 2, viewerHasReacted: true },
+ { content: '+1', count: 2, viewerHasReacted: true },
// Zero-count groups are dropped — the shipped contract; the mobile
// reactions row renders a fixed 8-pill set and hides zero counts, so
// dropping them does not change the rendered output.
- { content: "HEART", count: 7, viewerHasReacted: false },
+ { content: 'HEART', count: 7, viewerHasReacted: false },
]);
- expect(out.map((r) => r.content)).toEqual(["+1", "HEART"]);
+ expect(out.map(r => r.content)).toEqual(['+1', 'HEART']);
});
- it("coerces a truthy non-boolean viewerHasReacted to true (legacy GitHub quirk)", () => {
+ it('coerces a truthy non-boolean viewerHasReacted to true (legacy GitHub quirk)', () => {
// The legacy normalizeReactions wrapper called `Boolean(...)`; preserve
// that contract even when GitHub occasionally returns truthy non-booleans.
const out = normalizeReactions_FOR_TEST([
{
- content: "ROCKET",
+ content: 'ROCKET',
viewerHasReacted: 1 as unknown as boolean,
reactors: { totalCount: 1 },
},
@@ -81,23 +79,23 @@ describe("normalizeReactions (reactionGroups shape)", () => {
expect(out[0]?.viewerHasReacted).toBe(true);
});
- it("returns an empty array for an empty input (no spurious entries)", () => {
+ it('returns an empty array for an empty input (no spurious entries)', () => {
expect(normalizeReactions_FOR_TEST([])).toEqual([]);
});
});
-describe("normalizeComment (reactionGroups shape)", () => {
- it("reads node.reactionGroups and forwards the same DTO shape", () => {
+describe('normalizeComment (reactionGroups shape)', () => {
+ it('reads node.reactionGroups and forwards the same DTO shape', () => {
const out = normalizeComment_FOR_TEST({
databaseId: 42,
- id: "node_42",
- body: "hello",
- createdAt: "2024-01-01T00:00:00Z",
- author: { login: "octocat", avatarUrl: "https://x/y.png" },
+ id: 'node_42',
+ body: 'hello',
+ createdAt: '2024-01-01T00:00:00Z',
+ author: { login: 'octocat', avatarUrl: 'https://x/y.png' },
reactionGroups: [
- { content: "+1", viewerHasReacted: false, reactors: { totalCount: 1 } },
+ { content: '+1', viewerHasReacted: false, reactors: { totalCount: 1 } },
{
- content: "EYES",
+ content: 'EYES',
viewerHasReacted: true,
reactors: { totalCount: 4 },
},
@@ -105,17 +103,17 @@ describe("normalizeComment (reactionGroups shape)", () => {
});
expect(out.databaseId).toBe(42);
expect(out.reactions).toEqual([
- { content: "+1", count: 1, viewerHasReacted: false },
- { content: "EYES", count: 4, viewerHasReacted: true },
+ { content: '+1', count: 1, viewerHasReacted: false },
+ { content: 'EYES', count: 4, viewerHasReacted: true },
]);
});
- it("defaults reactionGroups to [] when the field is absent or null", () => {
+ it('defaults reactionGroups to [] when the field is absent or null', () => {
const out = normalizeComment_FOR_TEST({
databaseId: 1,
- id: "node_1",
- body: "",
- createdAt: "2024-01-01T00:00:00Z",
+ id: 'node_1',
+ body: '',
+ createdAt: '2024-01-01T00:00:00Z',
author: null,
// `reactionGroups` omitted on purpose.
} as unknown as Parameters[0]);
diff --git a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
index f97cf0f301..a85bb251e1 100644
--- a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
+++ b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
@@ -5,7 +5,7 @@ import {
CONVERSATION_COMMENTS_QUERY_FOR_TEST,
fetchAllThreadComments,
REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST,
-} from "@/routers/github-pr-review-router";
+} from '@/routers/github-pr-review-router';
function commentNode(
id: number,
@@ -13,58 +13,58 @@ function commentNode(
content: string;
viewerHasReacted: boolean;
reactors: { totalCount: number };
- }>,
+ }>
) {
return {
databaseId: id,
id: `node_${id}`,
body: `comment ${id}`,
- createdAt: "2024-01-01T00:00:00Z",
- author: { login: "octocat", avatarUrl: "https://x/y.png" },
+ createdAt: '2024-01-01T00:00:00Z',
+ author: { login: 'octocat', avatarUrl: 'https://x/y.png' },
// Live GitHub shape: unpaginated reactionGroups list (all group types).
reactionGroups: reactionGroups ?? [
{
- content: "THUMBS_UP",
+ content: 'THUMBS_UP',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "THUMBS_DOWN",
+ content: 'THUMBS_DOWN',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "LAUGH",
+ content: 'LAUGH',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "HOORAY",
+ content: 'HOORAY',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "CONFUSED",
+ content: 'CONFUSED',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "HEART",
+ content: 'HEART',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "ROCKET",
+ content: 'ROCKET',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
- { content: "EYES", viewerHasReacted: false, reactors: { totalCount: 0 } },
+ { content: 'EYES', viewerHasReacted: false, reactors: { totalCount: 0 } },
],
};
}
-describe("fetchAllThreadComments", () => {
- it("follows the comment cursor until hasNextPage is false and uses only valid variables", async () => {
+describe('fetchAllThreadComments', () => {
+ it('follows the comment cursor until hasNextPage is false and uses only valid variables', async () => {
const request = jest
.fn()
// page 2
@@ -73,7 +73,7 @@ describe("fetchAllThreadComments", () => {
data: {
node: {
comments: {
- pageInfo: { hasNextPage: true, endCursor: "c2" },
+ pageInfo: { hasNextPage: true, endCursor: 'c2' },
nodes: [commentNode(2)],
},
},
@@ -98,107 +98,96 @@ describe("fetchAllThreadComments", () => {
const comments = await fetchAllThreadComments({
octokit,
- threadId: "thread_1",
+ threadId: 'thread_1',
initialConnection: {
- pageInfo: { hasNextPage: true, endCursor: "c1" },
+ pageInfo: { hasNextPage: true, endCursor: 'c1' },
nodes: [commentNode(1)],
},
});
// All three pages aggregated to completion — no silent truncation.
- expect(comments.map((c) => c.databaseId)).toEqual([1, 2, 3]);
+ expect(comments.map(c => c.databaseId)).toEqual([1, 2, 3]);
// Zero-count reactionGroups are dropped; DTO reactions stay empty.
- expect(comments.map((c) => c.reactions)).toEqual([[], [], []]);
+ expect(comments.map(c => c.reactions)).toEqual([[], [], []]);
expect(request).toHaveBeenCalledTimes(2);
// GraphQL variables must be nested under `variables` (GitHub — and a
// faithful mock — ignore top-level params), and the follow-up query must
// reference only $threadId/$first/$after (no unused $owner/$name/$number).
- const [, firstArgs] = request.mock.calls[0] as [
- string,
- Record,
- ];
- expect(firstArgs.query).toBe(
- REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST,
- );
+ const [, firstArgs] = request.mock.calls[0] as [string, Record];
+ expect(firstArgs.query).toBe(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST);
expect(firstArgs).toEqual({
query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST,
- variables: { threadId: "thread_1", first: 50, after: "c1" },
+ variables: { threadId: 'thread_1', first: 50, after: 'c1' },
});
- expect(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST).not.toMatch(
- /\$owner|\$name|\$number/,
- );
+ expect(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST).not.toMatch(/\$owner|\$name|\$number/);
const [, secondArgs] = request.mock.calls[1] as [
string,
{ variables: Record },
];
- expect(secondArgs.variables.after).toBe("c2");
+ expect(secondArgs.variables.after).toBe('c2');
});
// Production GraphQL contract for top-level PR conversation comments.
// `reactors` is a connection; GitHub rejects the query without first/last.
// The local stub harness cannot catch a bare `reactors` regression.
- it("locks CONVERSATION_COMMENTS_QUERY load-bearing selection shape", () => {
- expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).toMatch(
- /query\s+PrReviewConversationComments\b/,
- );
+ it('locks CONVERSATION_COMMENTS_QUERY load-bearing selection shape', () => {
+ expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).toMatch(/query\s+PrReviewConversationComments\b/);
// Operation must select PR conversation comments (not review threads).
expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).toMatch(
- /pullRequest\s*\([^)]*\)\s*\{\s*comments\s*\(/,
- );
- expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).toContain(
- "reactors(first: 0)",
+ /pullRequest\s*\([^)]*\)\s*\{\s*comments\s*\(/
);
+ expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).toContain('reactors(first: 0)');
// Bare `reactors {` is invalid GraphQL against GitHub (connection needs first/last).
expect(CONVERSATION_COMMENTS_QUERY_FOR_TEST).not.toMatch(/reactors\s*\{/);
});
- it("keeps only reactionGroups with totalCount > 0 in the DTO shape", async () => {
+ it('keeps only reactionGroups with totalCount > 0 in the DTO shape', async () => {
const comments = await fetchAllThreadComments({
octokit: { request: jest.fn() } as never,
- threadId: "thread_1",
+ threadId: 'thread_1',
initialConnection: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [
commentNode(1, [
{
- content: "THUMBS_UP",
+ content: 'THUMBS_UP',
viewerHasReacted: true,
reactors: { totalCount: 3 },
},
{
- content: "THUMBS_DOWN",
+ content: 'THUMBS_DOWN',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "LAUGH",
+ content: 'LAUGH',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "HOORAY",
+ content: 'HOORAY',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "CONFUSED",
+ content: 'CONFUSED',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "HEART",
+ content: 'HEART',
viewerHasReacted: false,
reactors: { totalCount: 1 },
},
{
- content: "ROCKET",
+ content: 'ROCKET',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
{
- content: "EYES",
+ content: 'EYES',
viewerHasReacted: false,
reactors: { totalCount: 0 },
},
@@ -208,8 +197,8 @@ describe("fetchAllThreadComments", () => {
});
expect(comments[0]?.reactions).toEqual([
- { content: "THUMBS_UP", count: 3, viewerHasReacted: true },
- { content: "HEART", count: 1, viewerHasReacted: false },
+ { content: 'THUMBS_UP', count: 3, viewerHasReacted: true },
+ { content: 'HEART', count: 1, viewerHasReacted: false },
]);
});
});
diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts
index 635f632d53..8ad6655c72 100644
--- a/apps/web/src/routers/github-pr-review-router.ts
+++ b/apps/web/src/routers/github-pr-review-router.ts
@@ -1,17 +1,17 @@
-import "server-only";
+import 'server-only';
-import * as z from "zod";
-import { TRPCError } from "@trpc/server";
+import * as z from 'zod';
+import { TRPCError } from '@trpc/server';
-import { baseProcedure, createTRPCRouter } from "@/lib/trpc/init";
-import type { createGitHubPrReviewOctokit } from "@/lib/github-pr-review/client";
+import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init';
+import type { createGitHubPrReviewOctokit } from '@/lib/github-pr-review/client';
import {
buildChecksResult,
buildFilesPage,
buildOverviewDto,
buildReviewThreadsResult,
sliceFileLines,
-} from "@/lib/github-pr-review/mappers";
+} from '@/lib/github-pr-review/mappers';
import {
CONVERSATION_COMMENTS_MAX_PAGES,
CONVERSATION_COMMENTS_PAGE_SIZE,
@@ -19,12 +19,9 @@ import {
FILES_MAX_PAGES,
FILES_PAGE_SIZE,
REVIEW_THREADS_PAGE_SIZE,
-} from "@/lib/github-pr-review/dtos";
-import {
- throwTrpcFromGraphQlErrors,
- withGitHubUserTokenRetry,
-} from "@/lib/github-pr-review/retry";
-import { getGitHubUserAccessToken } from "@/lib/integrations/platforms/github/user-token-client";
+} from '@/lib/github-pr-review/dtos';
+import { throwTrpcFromGraphQlErrors, withGitHubUserTokenRetry } from '@/lib/github-pr-review/retry';
+import { getGitHubUserAccessToken } from '@/lib/integrations/platforms/github/user-token-client';
import {
AutoMergeMethodSchema,
CommentPositionSchema,
@@ -44,7 +41,7 @@ import {
buildSubmitReviewParams,
buildUnresolveThreadVariables,
buildUpdateBranchParams,
-} from "@/lib/github-pr-review/mutations";
+} from '@/lib/github-pr-review/mutations';
const ownerRepoRegex = /^[A-Za-z0-9_.-]+$/;
@@ -57,19 +54,15 @@ const ownerRepoSchema = z
const prNumberSchema = z.number().int().positive();
-const GetPullRequestInput = ownerRepoSchema
- .extend({ number: prNumberSchema })
- .strict();
+const GetPullRequestInput = ownerRepoSchema.extend({ number: prNumberSchema }).strict();
-const ListChecksInput = ownerRepoSchema
- .extend({ ref: z.string().min(1).max(255) })
- .strict();
+const ListChecksInput = ownerRepoSchema.extend({ ref: z.string().min(1).max(255) }).strict();
// tRPC's `useInfiniteQuery` integration injects a `direction` discriminator
// ('forward'|'backward') into the procedure input alongside `cursor`. The input
// stays `.strict()` (unknown fields still rejected), so it must accept it
// explicitly or every infinite-query page 400s.
-const infiniteQueryDirection = z.enum(["forward", "backward"]).optional();
+const infiniteQueryDirection = z.enum(['forward', 'backward']).optional();
const ListFilesInput = ownerRepoSchema
.extend({
@@ -87,8 +80,8 @@ const GetFileLinesInput = ownerRepoSchema
endLine: z.number().int().positive(),
})
.strict()
- .refine((v) => v.endLine >= v.startLine, {
- message: "endLine must be >= startLine",
+ .refine(v => v.endLine >= v.startLine, {
+ message: 'endLine must be >= startLine',
});
const ListReviewThreadsInput = ownerRepoSchema
@@ -111,13 +104,13 @@ const CreateReviewCommentInput = ownerRepoSchema
commitSha: z.string().min(40).max(64),
})
.strict()
- .refine((v) => v.startLine === undefined || v.startLine <= v.line, {
- message: "startLine must be <= line",
- path: ["startLine"],
+ .refine(v => v.startLine === undefined || v.startLine <= v.line, {
+ message: 'startLine must be <= line',
+ path: ['startLine'],
})
- .refine((v) => (v.startLine === undefined) === (v.startSide === undefined), {
- message: "startLine and startSide must be provided together",
- path: ["startSide"],
+ .refine(v => (v.startLine === undefined) === (v.startSide === undefined), {
+ message: 'startLine and startSide must be provided together',
+ path: ['startSide'],
});
const ReplyToCommentInput = ownerRepoSchema
@@ -138,16 +131,14 @@ const SubmitReviewInput = ownerRepoSchema
.array(
CommentPositionSchema.extend({
body: z.string().min(1).max(65_535),
- }).strict(),
+ }).strict()
)
.max(100)
.optional(),
})
.strict();
-const ThreadIdInput = z
- .object({ threadId: z.string().min(1).max(256) })
- .strict();
+const ThreadIdInput = z.object({ threadId: z.string().min(1).max(256) }).strict();
const ReactionInput = z
.object({
@@ -428,24 +419,24 @@ type GraphQlReviewThreadNode = {
id: string;
isResolved: boolean;
isOutdated: boolean;
- subjectType: "LINE" | "FILE" | null;
+ subjectType: 'LINE' | 'FILE' | null;
path: string | null;
line: number | null;
startLine: number | null;
originalLine: number | null;
originalStartLine: number | null;
- diffSide: "LEFT" | "RIGHT" | null;
+ diffSide: 'LEFT' | 'RIGHT' | null;
comments: GraphQlCommentConnection;
};
function normalizeReactions(groups: GraphQlReactionGroup[]) {
return groups
- .map((g) => ({
+ .map(g => ({
content: g.content,
count: g.reactors?.totalCount ?? 0,
viewerHasReacted: Boolean(g.viewerHasReacted),
}))
- .filter((r) => r.count > 0);
+ .filter(r => r.count > 0);
}
function normalizeComment(node: GraphQlCommentNode) {
@@ -460,8 +451,7 @@ function normalizeComment(node: GraphQlCommentNode) {
}
// Exported for unit testing the follow-up pagination loop.
-export const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST =
- REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY;
+export const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST = REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY;
export const CONVERSATION_COMMENTS_QUERY_FOR_TEST = CONVERSATION_COMMENTS_QUERY;
// All raw PR-Review GraphQL documents defined in this router, collected as a
@@ -502,7 +492,7 @@ export async function fetchAllThreadComments(args: {
// Follow the comment cursor until GitHub reports no next page, so DTO
// threads always carry the complete comment list (no silent truncation).
while (hasNext && cursor) {
- const response = (await octokit.request("POST /graphql", {
+ const response = (await octokit.request('POST /graphql', {
query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY,
variables: { threadId, first: 50, after: cursor },
})) as {
@@ -529,7 +519,7 @@ async function fetchConversationCommentsPage(args: {
cursor: string | null;
}): Promise {
const { octokit, owner, repo, number, cursor } = args;
- const response = (await octokit.request("POST /graphql", {
+ const response = (await octokit.request('POST /graphql', {
query: CONVERSATION_COMMENTS_QUERY,
variables: {
owner,
@@ -594,7 +584,7 @@ async function fetchReviewThreadsPage(args: {
cursor: string | null;
}) {
const { octokit, owner, repo, number, cursor } = args;
- const response = (await octokit.request("POST /graphql", {
+ const response = (await octokit.request('POST /graphql', {
query: REVIEW_THREADS_QUERY,
variables: {
owner,
@@ -635,7 +625,7 @@ async function runGraphQlMutation(args: {
variables: Record;
}): Promise {
const { octokit, query, variables } = args;
- const response = (await octokit.request("POST /graphql", {
+ const response = (await octokit.request('POST /graphql', {
query,
variables,
})) as GraphQlMutationResponse;
@@ -643,8 +633,8 @@ async function runGraphQlMutation(args: {
const payload = response.data.data;
if (payload === null || payload === undefined) {
throw new TRPCError({
- code: "BAD_GATEWAY",
- message: "GitHub returned an empty GraphQL response",
+ code: 'BAD_GATEWAY',
+ message: 'GitHub returned an empty GraphQL response',
});
}
return payload;
@@ -653,13 +643,10 @@ async function runGraphQlMutation(args: {
// A GraphQL mutation whose top-level operation field is null (with no errors[])
// means GitHub did not perform the action — surface a deliberate failure rather
// than reporting a synthesized success.
-function requireGraphQlOperation(
- value: T | null | undefined,
- operation: string,
-): T {
+function requireGraphQlOperation(value: T | null | undefined, operation: string): T {
if (value === null || value === undefined) {
throw new TRPCError({
- code: "BAD_GATEWAY",
+ code: 'BAD_GATEWAY',
message: `GitHub did not confirm the ${operation} operation`,
});
}
@@ -667,222 +654,206 @@ function requireGraphQlOperation(
}
export const githubPrReviewRouter = createTRPCRouter({
- getPullRequest: baseProcedure
- .input(GetPullRequestInput)
- .query(async ({ ctx, input }) => {
- const overview = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- // Raw GitHub errors propagate to withGitHubUserTokenRetry, which
- // handles 401 rotation and classifies everything else.
- const pullsResp = await octokit.pulls.get({
- owner: input.owner,
- repo: input.repo,
- pull_number: input.number,
- });
- const pr = pullsResp.data;
- const repoResp = await octokit.repos.get({
- owner: input.owner,
- repo: input.repo,
- });
- const repo = repoResp.data;
- // GraphQL for reviewDecision + viewer.login
- type OverviewGraphQl = {
- repository: {
- pullRequest: { reviewDecision: string | null } | null;
- } | null;
- viewer: { login: string } | null;
- };
- let graphQl: OverviewGraphQl | null = null;
- try {
- const gqlResp = (await octokit.request("POST /graphql", {
- query: PULL_REQUEST_FRAGMENT_QUERY,
- variables: {
- owner: input.owner,
- name: input.repo,
- number: input.number,
- },
- })) as { data: { data: OverviewGraphQl | null; errors?: unknown } };
- throwTrpcFromGraphQlErrors(gqlResp.data.errors as never);
- graphQl = gqlResp.data.data ?? null;
- } catch (error) {
- if (error instanceof TRPCError) throw error;
- // A raw 401 must reach withGitHubUserTokenRetry so it can rotate the
- // credential (and report a terminal rejection) — never silently
- // degrade an authorization failure.
- if (
- error !== null &&
- typeof error === "object" &&
- (error as { status?: number }).status === 401
- ) {
- throw error;
- }
- // Other GraphQL failures (5xx, field errors) should not block the
- // rest of the overview — degrade the reviewDecision/viewer enrichment.
- graphQl = null;
- }
- return buildOverviewDto({
- pr: pr as never,
- repo: repo as never,
- graphQl,
- viewer: graphQl?.viewer ?? null,
- });
- },
- });
- return overview;
- }),
-
- listChecks: baseProcedure
- .input(ListChecksInput)
- .query(async ({ ctx, input }) => {
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const checkRuns = await octokit.paginate(octokit.checks.listForRef, {
- owner: input.owner,
- repo: input.repo,
- ref: input.ref,
- per_page: 100,
- });
- const statuses = await octokit.paginate(
- octokit.repos.listCommitStatusesForRef,
- {
+ getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => {
+ const overview = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ // Raw GitHub errors propagate to withGitHubUserTokenRetry, which
+ // handles 401 rotation and classifies everything else.
+ const pullsResp = await octokit.pulls.get({
+ owner: input.owner,
+ repo: input.repo,
+ pull_number: input.number,
+ });
+ const pr = pullsResp.data;
+ const repoResp = await octokit.repos.get({
+ owner: input.owner,
+ repo: input.repo,
+ });
+ const repo = repoResp.data;
+ // GraphQL for reviewDecision + viewer.login
+ type OverviewGraphQl = {
+ repository: {
+ pullRequest: { reviewDecision: string | null } | null;
+ } | null;
+ viewer: { login: string } | null;
+ };
+ let graphQl: OverviewGraphQl | null = null;
+ try {
+ const gqlResp = (await octokit.request('POST /graphql', {
+ query: PULL_REQUEST_FRAGMENT_QUERY,
+ variables: {
owner: input.owner,
- repo: input.repo,
- ref: input.ref,
- per_page: 100,
+ name: input.repo,
+ number: input.number,
},
- );
- return buildChecksResult({
- checkRuns: checkRuns as never,
- commitStatuses: statuses as never,
- });
- },
- });
- }),
+ })) as { data: { data: OverviewGraphQl | null; errors?: unknown } };
+ throwTrpcFromGraphQlErrors(gqlResp.data.errors as never);
+ graphQl = gqlResp.data.data ?? null;
+ } catch (error) {
+ if (error instanceof TRPCError) throw error;
+ // A raw 401 must reach withGitHubUserTokenRetry so it can rotate the
+ // credential (and report a terminal rejection) — never silently
+ // degrade an authorization failure.
+ if (
+ error !== null &&
+ typeof error === 'object' &&
+ (error as { status?: number }).status === 401
+ ) {
+ throw error;
+ }
+ // Other GraphQL failures (5xx, field errors) should not block the
+ // rest of the overview — degrade the reviewDecision/viewer enrichment.
+ graphQl = null;
+ }
+ return buildOverviewDto({
+ pr: pr as never,
+ repo: repo as never,
+ graphQl,
+ viewer: graphQl?.viewer ?? null,
+ });
+ },
+ });
+ return overview;
+ }),
- listFiles: baseProcedure
- .input(ListFilesInput)
- .query(async ({ ctx, input }) => {
- const page = input.cursor ?? 1;
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const response = await octokit.pulls.listFiles({
- owner: input.owner,
- repo: input.repo,
- pull_number: input.number,
- page,
- per_page: FILES_PAGE_SIZE,
- });
- return buildFilesPage({
- page,
- perPage: FILES_PAGE_SIZE,
- rawFiles: response.data as never,
+ listChecks: baseProcedure.input(ListChecksInput).query(async ({ ctx, input }) => {
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const checkRuns = await octokit.paginate(octokit.checks.listForRef, {
+ owner: input.owner,
+ repo: input.repo,
+ ref: input.ref,
+ per_page: 100,
+ });
+ const statuses = await octokit.paginate(octokit.repos.listCommitStatusesForRef, {
+ owner: input.owner,
+ repo: input.repo,
+ ref: input.ref,
+ per_page: 100,
+ });
+ return buildChecksResult({
+ checkRuns: checkRuns as never,
+ commitStatuses: statuses as never,
+ });
+ },
+ });
+ }),
+
+ listFiles: baseProcedure.input(ListFilesInput).query(async ({ ctx, input }) => {
+ const page = input.cursor ?? 1;
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const response = await octokit.pulls.listFiles({
+ owner: input.owner,
+ repo: input.repo,
+ pull_number: input.number,
+ page,
+ per_page: FILES_PAGE_SIZE,
+ });
+ return buildFilesPage({
+ page,
+ perPage: FILES_PAGE_SIZE,
+ rawFiles: response.data as never,
+ });
+ },
+ });
+ }),
+
+ getFileLines: baseProcedure.input(GetFileLinesInput).query(async ({ ctx, input }) => {
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const response = await octokit.repos.getContent({
+ owner: input.owner,
+ repo: input.repo,
+ path: input.path,
+ ref: input.ref,
+ mediaType: { format: 'raw' },
+ });
+ const data = response.data as unknown;
+ if (typeof data !== 'string') {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Requested path is not a file',
});
- },
- });
- }),
+ }
+ const cappedEnd = Math.min(input.endLine, input.startLine + FILE_LINES_MAX - 1);
+ return sliceFileLines({
+ rawContent: data,
+ startLine: input.startLine,
+ endLine: cappedEnd,
+ });
+ },
+ });
+ }),
- getFileLines: baseProcedure
- .input(GetFileLinesInput)
- .query(async ({ ctx, input }) => {
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const response = await octokit.repos.getContent({
+ listReviewThreads: baseProcedure.input(ListReviewThreadsInput).query(async ({ ctx, input }) => {
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const isFirstPage = input.cursor == null;
+ const [connection, conversation] = await Promise.all([
+ fetchReviewThreadsPage({
+ octokit,
owner: input.owner,
repo: input.repo,
- path: input.path,
- ref: input.ref,
- mediaType: { format: "raw" },
- });
- const data = response.data as unknown;
- if (typeof data !== "string") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Requested path is not a file",
- });
- }
- const cappedEnd = Math.min(
- input.endLine,
- input.startLine + FILE_LINES_MAX - 1,
- );
- return sliceFileLines({
- rawContent: data,
- startLine: input.startLine,
- endLine: cappedEnd,
- });
- },
- });
- }),
-
- listReviewThreads: baseProcedure
- .input(ListReviewThreadsInput)
- .query(async ({ ctx, input }) => {
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const isFirstPage = input.cursor == null;
- const [connection, conversation] = await Promise.all([
- fetchReviewThreadsPage({
- octokit,
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- cursor: input.cursor ?? null,
- }),
- // Conversation comments only on the first page; cursored pages get [].
- isFirstPage
- ? fetchAllConversationComments({
- octokit,
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- })
- : Promise.resolve([]),
- ]);
- if (!connection) {
- return buildReviewThreadsResult({
- threads: [],
- conversation,
- page: 1,
- hasNextPage: false,
- endCursor: null,
- });
- }
- const threads = await Promise.all(
- connection.nodes.map(async (node) => {
- const comments = await fetchAllThreadComments({
+ number: input.number,
+ cursor: input.cursor ?? null,
+ }),
+ // Conversation comments only on the first page; cursored pages get [].
+ isFirstPage
+ ? fetchAllConversationComments({
octokit,
- threadId: node.id,
- initialConnection: node.comments,
- });
- return {
- id: node.id,
- isResolved: node.isResolved,
- isOutdated: node.isOutdated,
- subjectType: node.subjectType,
- path: node.path,
- line: node.line,
- startLine: node.startLine,
- originalLine: node.originalLine,
- originalStartLine: node.originalStartLine,
- diffSide: node.diffSide,
- comments,
- };
- }),
- );
+ owner: input.owner,
+ repo: input.repo,
+ number: input.number,
+ })
+ : Promise.resolve([]),
+ ]);
+ if (!connection) {
return buildReviewThreadsResult({
- threads: threads as never,
+ threads: [],
conversation,
page: 1,
- hasNextPage: connection.pageInfo.hasNextPage,
- endCursor: connection.pageInfo.endCursor,
+ hasNextPage: false,
+ endCursor: null,
});
- },
- });
- }),
+ }
+ const threads = await Promise.all(
+ connection.nodes.map(async node => {
+ const comments = await fetchAllThreadComments({
+ octokit,
+ threadId: node.id,
+ initialConnection: node.comments,
+ });
+ return {
+ id: node.id,
+ isResolved: node.isResolved,
+ isOutdated: node.isOutdated,
+ subjectType: node.subjectType,
+ path: node.path,
+ line: node.line,
+ startLine: node.startLine,
+ originalLine: node.originalLine,
+ originalStartLine: node.originalStartLine,
+ diffSide: node.diffSide,
+ comments,
+ };
+ })
+ );
+ return buildReviewThreadsResult({
+ threads: threads as never,
+ conversation,
+ page: 1,
+ hasNextPage: connection.pageInfo.hasNextPage,
+ endCursor: connection.pageInfo.endCursor,
+ });
+ },
+ });
+ }),
// Post a single immediate review comment (no pending review required).
createReviewComment: baseProcedure
@@ -890,7 +861,7 @@ export const githubPrReviewRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const result = await withGitHubUserTokenRetry({
kiloUserId: ctx.user.id,
- call: async (octokit) => {
+ call: async octokit => {
const params = buildCreateReviewCommentParams({
owner: input.owner,
repo: input.repo,
@@ -915,152 +886,136 @@ export const githubPrReviewRouter = createTRPCRouter({
// Reply to an existing review comment (creates a child comment in the
// same thread).
- replyToComment: baseProcedure
- .input(ReplyToCommentInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const params = buildReplyToCommentParams({
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- commentId: input.commentId,
- body: input.body,
- });
- const response =
- await octokit.pulls.createReplyForReviewComment(params);
- return {
- commentId: response.data.id,
- nodeId: response.data.node_id,
- };
- },
- });
- return result;
- }),
+ replyToComment: baseProcedure.input(ReplyToCommentInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const params = buildReplyToCommentParams({
+ owner: input.owner,
+ repo: input.repo,
+ number: input.number,
+ commentId: input.commentId,
+ body: input.body,
+ });
+ const response = await octokit.pulls.createReplyForReviewComment(params);
+ return {
+ commentId: response.data.id,
+ nodeId: response.data.node_id,
+ };
+ },
+ });
+ return result;
+ }),
// Submit a pending review with an optional batch of inline comments and
// an overall event (APPROVE / REQUEST_CHANGES / COMMENT).
- submitReview: baseProcedure
- .input(SubmitReviewInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const params = buildSubmitReviewParams({
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- event: input.event,
- body: input.body,
- commitSha: input.commitSha,
- comments: input.comments,
- });
- const response = await octokit.pulls.createReview(params);
- return {
- reviewId: response.data.id,
- nodeId: response.data.node_id,
- state: response.data.state,
- };
- },
- });
- return result;
- }),
+ submitReview: baseProcedure.input(SubmitReviewInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const params = buildSubmitReviewParams({
+ owner: input.owner,
+ repo: input.repo,
+ number: input.number,
+ event: input.event,
+ body: input.body,
+ commitSha: input.commitSha,
+ comments: input.comments,
+ });
+ const response = await octokit.pulls.createReview(params);
+ return {
+ reviewId: response.data.id,
+ nodeId: response.data.node_id,
+ state: response.data.state,
+ };
+ },
+ });
+ return result;
+ }),
// Resolve a review thread (GraphQL — there is no REST endpoint for this).
- resolveThread: baseProcedure
- .input(ThreadIdInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildResolveThreadVariables({
- threadId: input.threadId,
- });
- const payload = await runGraphQlMutation<{
- resolveReviewThread: {
- thread: { id: string; isResolved: boolean };
- } | null;
- }>({ octokit, query: RESOLVE_THREAD_MUTATION, variables });
- const thread = requireGraphQlOperation(
- payload.resolveReviewThread?.thread,
- "resolveReviewThread",
- );
- return { threadId: thread.id, isResolved: thread.isResolved };
- },
- });
- return result;
- }),
+ resolveThread: baseProcedure.input(ThreadIdInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildResolveThreadVariables({
+ threadId: input.threadId,
+ });
+ const payload = await runGraphQlMutation<{
+ resolveReviewThread: {
+ thread: { id: string; isResolved: boolean };
+ } | null;
+ }>({ octokit, query: RESOLVE_THREAD_MUTATION, variables });
+ const thread = requireGraphQlOperation(
+ payload.resolveReviewThread?.thread,
+ 'resolveReviewThread'
+ );
+ return { threadId: thread.id, isResolved: thread.isResolved };
+ },
+ });
+ return result;
+ }),
- unresolveThread: baseProcedure
- .input(ThreadIdInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildUnresolveThreadVariables({
- threadId: input.threadId,
- });
- const payload = await runGraphQlMutation<{
- unresolveReviewThread: {
- thread: { id: string; isResolved: boolean };
- } | null;
- }>({ octokit, query: UNRESOLVE_THREAD_MUTATION, variables });
- const thread = requireGraphQlOperation(
- payload.unresolveReviewThread?.thread,
- "unresolveReviewThread",
- );
- return { threadId: thread.id, isResolved: thread.isResolved };
- },
- });
- return result;
- }),
+ unresolveThread: baseProcedure.input(ThreadIdInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildUnresolveThreadVariables({
+ threadId: input.threadId,
+ });
+ const payload = await runGraphQlMutation<{
+ unresolveReviewThread: {
+ thread: { id: string; isResolved: boolean };
+ } | null;
+ }>({ octokit, query: UNRESOLVE_THREAD_MUTATION, variables });
+ const thread = requireGraphQlOperation(
+ payload.unresolveReviewThread?.thread,
+ 'unresolveReviewThread'
+ );
+ return { threadId: thread.id, isResolved: thread.isResolved };
+ },
+ });
+ return result;
+ }),
- addReaction: baseProcedure
- .input(ReactionInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildAddReactionVariables({
- commentNodeId: input.commentNodeId,
- content: input.content,
- });
- const payload = await runGraphQlMutation<{
- addReaction: { reaction: { content: string } } | null;
- }>({ octokit, query: ADD_REACTION_MUTATION, variables });
- const reaction = requireGraphQlOperation(
- payload.addReaction?.reaction,
- "addReaction",
- );
- return { content: reaction.content };
- },
- });
- return result;
- }),
+ addReaction: baseProcedure.input(ReactionInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildAddReactionVariables({
+ commentNodeId: input.commentNodeId,
+ content: input.content,
+ });
+ const payload = await runGraphQlMutation<{
+ addReaction: { reaction: { content: string } } | null;
+ }>({ octokit, query: ADD_REACTION_MUTATION, variables });
+ const reaction = requireGraphQlOperation(payload.addReaction?.reaction, 'addReaction');
+ return { content: reaction.content };
+ },
+ });
+ return result;
+ }),
- removeReaction: baseProcedure
- .input(ReactionInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildRemoveReactionVariables({
- commentNodeId: input.commentNodeId,
- content: input.content,
- });
- const payload = await runGraphQlMutation<{
- removeReaction: { reaction: { content: string } } | null;
- }>({ octokit, query: REMOVE_REACTION_MUTATION, variables });
- const reaction = requireGraphQlOperation(
- payload.removeReaction?.reaction,
- "removeReaction",
- );
- return { content: reaction.content };
- },
- });
- return result;
- }),
+ removeReaction: baseProcedure.input(ReactionInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildRemoveReactionVariables({
+ commentNodeId: input.commentNodeId,
+ content: input.content,
+ });
+ const payload = await runGraphQlMutation<{
+ removeReaction: { reaction: { content: string } } | null;
+ }>({ octokit, query: REMOVE_REACTION_MUTATION, variables });
+ const reaction = requireGraphQlOperation(
+ payload.removeReaction?.reaction,
+ 'removeReaction'
+ );
+ return { content: reaction.content };
+ },
+ });
+ return result;
+ }),
// Merge a pull request. `expectedHeadSha` enforces the optimistic-concurrency
// fence — if the head moved since the mobile overview was rendered, GitHub
@@ -1074,165 +1029,153 @@ export const githubPrReviewRouter = createTRPCRouter({
// arbitrary same-repo ref (e.g. `main`) by spoofing `headRef`. The delete
// is fenced on the server-derived head sha matching `expectedHeadSha`,
// same-repo identity, and the merge actually completing.
- mergePullRequest: baseProcedure
- .input(MergePullRequestInput)
- .mutation(async ({ ctx, input }) => {
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- // Fetch the PR first so we know the authoritative head ref, head sha,
- // and whether the head repo is the same as the base repo. A merge
- // does not move the head branch, so the ref/sha derived here are
- // valid for the post-merge delete decision.
- const prResp = await octokit.pulls.get({
- owner: input.owner,
- repo: input.repo,
- pull_number: input.number,
- });
- const pr = prResp.data;
- const headRepo = pr.head?.repo ?? null;
- const baseRepo = pr.base?.repo ?? null;
- // Treat a null/absent head repo (e.g. deleted fork) as not-deletable;
- // also bail if base.repo is missing for the same reason. Compare the
- // numeric repo id — robust against name/owner changes.
- const sameRepo =
- headRepo !== null &&
- baseRepo !== null &&
- typeof headRepo.id === "number" &&
- typeof baseRepo.id === "number" &&
- headRepo.id === baseRepo.id;
- const fetchedHeadSha =
- typeof pr.head?.sha === "string" ? pr.head.sha : null;
- const headRefName =
- typeof pr.head?.ref === "string" ? pr.head.ref : null;
+ mergePullRequest: baseProcedure.input(MergePullRequestInput).mutation(async ({ ctx, input }) => {
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ // Fetch the PR first so we know the authoritative head ref, head sha,
+ // and whether the head repo is the same as the base repo. A merge
+ // does not move the head branch, so the ref/sha derived here are
+ // valid for the post-merge delete decision.
+ const prResp = await octokit.pulls.get({
+ owner: input.owner,
+ repo: input.repo,
+ pull_number: input.number,
+ });
+ const pr = prResp.data;
+ const headRepo = pr.head?.repo ?? null;
+ const baseRepo = pr.base?.repo ?? null;
+ // Treat a null/absent head repo (e.g. deleted fork) as not-deletable;
+ // also bail if base.repo is missing for the same reason. Compare the
+ // numeric repo id — robust against name/owner changes.
+ const sameRepo =
+ headRepo !== null &&
+ baseRepo !== null &&
+ typeof headRepo.id === 'number' &&
+ typeof baseRepo.id === 'number' &&
+ headRepo.id === baseRepo.id;
+ const fetchedHeadSha = typeof pr.head?.sha === 'string' ? pr.head.sha : null;
+ const headRefName = typeof pr.head?.ref === 'string' ? pr.head.ref : null;
- const params = buildMergePullRequestParams({
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- method: input.method,
- commitTitle: input.commitTitle,
- commitMessage: input.commitMessage,
- expectedHeadSha: input.expectedHeadSha,
- });
- const response = await octokit.pulls.merge(params);
- const merged = Boolean(response.data.merged);
- if (
- !merged ||
- !input.deleteBranch ||
- !sameRepo ||
- headRefName === null ||
- fetchedHeadSha === null ||
- fetchedHeadSha !== input.expectedHeadSha
- ) {
- return {
- merged,
- sha: response.data.sha,
- branchDeleted: false as const,
- };
- }
- // Best-effort: only call deleteRef when the server-derived head is
- // same-repo AND the head sha we fetched matches what the caller
- // claimed to merge. Catch every error and surface it in the result
- // instead of failing the whole mutation.
- try {
- await octokit.git.deleteRef(
- buildDeleteRefParams({
- owner: input.owner,
- repo: input.repo,
- headRef: headRefName,
- }),
- );
- return {
- merged: true as const,
- sha: response.data.sha,
- branchDeleted: true as const,
- };
- } catch (error) {
- const message =
- error instanceof Error && error.message
- ? error.message
- : "Branch delete failed";
- return {
- merged: true as const,
- sha: response.data.sha,
- branchDeleted: false as const,
- branchDeleteError: message,
- };
- }
- },
- });
- }),
+ const params = buildMergePullRequestParams({
+ owner: input.owner,
+ repo: input.repo,
+ number: input.number,
+ method: input.method,
+ commitTitle: input.commitTitle,
+ commitMessage: input.commitMessage,
+ expectedHeadSha: input.expectedHeadSha,
+ });
+ const response = await octokit.pulls.merge(params);
+ const merged = Boolean(response.data.merged);
+ if (
+ !merged ||
+ !input.deleteBranch ||
+ !sameRepo ||
+ headRefName === null ||
+ fetchedHeadSha === null ||
+ fetchedHeadSha !== input.expectedHeadSha
+ ) {
+ return {
+ merged,
+ sha: response.data.sha,
+ branchDeleted: false as const,
+ };
+ }
+ // Best-effort: only call deleteRef when the server-derived head is
+ // same-repo AND the head sha we fetched matches what the caller
+ // claimed to merge. Catch every error and surface it in the result
+ // instead of failing the whole mutation.
+ try {
+ await octokit.git.deleteRef(
+ buildDeleteRefParams({
+ owner: input.owner,
+ repo: input.repo,
+ headRef: headRefName,
+ })
+ );
+ return {
+ merged: true as const,
+ sha: response.data.sha,
+ branchDeleted: true as const,
+ };
+ } catch (error) {
+ const message =
+ error instanceof Error && error.message ? error.message : 'Branch delete failed';
+ return {
+ merged: true as const,
+ sha: response.data.sha,
+ branchDeleted: false as const,
+ branchDeleteError: message,
+ };
+ }
+ },
+ });
+ }),
// Update a PR's head branch from its base (the "Update branch" button).
// `expectedHeadSha` is the same stale-screen fence as merge; a mismatch
// 422s and the classifier surfaces it as BAD_REQUEST / CONFLICT.
- updateBranch: baseProcedure
- .input(UpdateBranchInput)
- .mutation(async ({ ctx, input }) => {
- return withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const params = buildUpdateBranchParams({
- owner: input.owner,
- repo: input.repo,
- number: input.number,
- expectedHeadSha: input.expectedHeadSha,
- });
- const response = await octokit.pulls.updateBranch(params);
- return {
- message: response.data.message,
- };
- },
- });
- }),
+ updateBranch: baseProcedure.input(UpdateBranchInput).mutation(async ({ ctx, input }) => {
+ return withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const params = buildUpdateBranchParams({
+ owner: input.owner,
+ repo: input.repo,
+ number: input.number,
+ expectedHeadSha: input.expectedHeadSha,
+ });
+ const response = await octokit.pulls.updateBranch(params);
+ return {
+ message: response.data.message,
+ };
+ },
+ });
+ }),
- enableAutoMerge: baseProcedure
- .input(AutoMergeInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildEnableAutoMergeVariables({
- prNodeId: input.prNodeId,
- method: input.method ?? "MERGE",
- commitTitle: input.commitTitle,
- commitMessage: input.commitMessage,
- });
- const payload = await runGraphQlMutation<{
- enablePullRequestAutoMerge: { pullRequest: { id: string } } | null;
- }>({ octokit, query: ENABLE_AUTO_MERGE_MUTATION, variables });
- const pullRequest = requireGraphQlOperation(
- payload.enablePullRequestAutoMerge?.pullRequest,
- "enablePullRequestAutoMerge",
- );
- return { enabled: true as const, prNodeId: pullRequest.id };
- },
- });
- return result;
- }),
+ enableAutoMerge: baseProcedure.input(AutoMergeInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildEnableAutoMergeVariables({
+ prNodeId: input.prNodeId,
+ method: input.method ?? 'MERGE',
+ commitTitle: input.commitTitle,
+ commitMessage: input.commitMessage,
+ });
+ const payload = await runGraphQlMutation<{
+ enablePullRequestAutoMerge: { pullRequest: { id: string } } | null;
+ }>({ octokit, query: ENABLE_AUTO_MERGE_MUTATION, variables });
+ const pullRequest = requireGraphQlOperation(
+ payload.enablePullRequestAutoMerge?.pullRequest,
+ 'enablePullRequestAutoMerge'
+ );
+ return { enabled: true as const, prNodeId: pullRequest.id };
+ },
+ });
+ return result;
+ }),
- disableAutoMerge: baseProcedure
- .input(AutoMergeInput)
- .mutation(async ({ ctx, input }) => {
- const result = await withGitHubUserTokenRetry({
- kiloUserId: ctx.user.id,
- call: async (octokit) => {
- const variables = buildDisableAutoMergeVariables({
- prNodeId: input.prNodeId,
- });
- const payload = await runGraphQlMutation<{
- disablePullRequestAutoMerge: { pullRequest: { id: string } } | null;
- }>({ octokit, query: DISABLE_AUTO_MERGE_MUTATION, variables });
- const pullRequest = requireGraphQlOperation(
- payload.disablePullRequestAutoMerge?.pullRequest,
- "disablePullRequestAutoMerge",
- );
- return { enabled: false as const, prNodeId: pullRequest.id };
- },
- });
- return result;
- }),
+ disableAutoMerge: baseProcedure.input(AutoMergeInput).mutation(async ({ ctx, input }) => {
+ const result = await withGitHubUserTokenRetry({
+ kiloUserId: ctx.user.id,
+ call: async octokit => {
+ const variables = buildDisableAutoMergeVariables({
+ prNodeId: input.prNodeId,
+ });
+ const payload = await runGraphQlMutation<{
+ disablePullRequestAutoMerge: { pullRequest: { id: string } } | null;
+ }>({ octokit, query: DISABLE_AUTO_MERGE_MUTATION, variables });
+ const pullRequest = requireGraphQlOperation(
+ payload.disablePullRequestAutoMerge?.pullRequest,
+ 'disablePullRequestAutoMerge'
+ );
+ return { enabled: false as const, prNodeId: pullRequest.id };
+ },
+ });
+ return result;
+ }),
});
// Re-export the disconnected helper used by callers that want to surface a
From 3b5d1b89156bc08d6b55c21c5492be984c192423 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Mon, 27 Jul 2026 17:20:01 +0200
Subject: [PATCH 18/19] chore: retrigger Kilo Code Review on the integrated
head
From d700bf92eb6782644b4702791d727d1c61c73840 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
Date: Mon, 27 Jul 2026 18:11:17 +0200
Subject: [PATCH 19/19] chore: retrigger Kilo Code Review on the integrated
head