diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 23dae3a678..85099c8799 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -98,6 +98,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set ({ ...model, showGatewayMetadata: false })); - -const repositoryNames = [ - 'api', - 'billing-service', - 'cli', - 'cloud', - 'design-system', - 'docs', - 'event-pipeline', - 'examples', - 'frontend', - 'github-bot', - 'infrastructure', - 'integrations', - 'internal-tools', - 'mobile', - 'observability', - 'payments', - 'platform', - 'sdk', - 'search', - 'security', - 'shared-components', - 'status-page', - 'terraform-modules', - 'webhooks', -]; - -const repositoryOverrides: Partial> = { - api: 'anthropic/claude-opus-4.6', - cli: 'openai/gpt-5.2', - docs: 'kilo-auto/frontier', - frontend: 'google/gemini-3-pro-preview', -}; - -const reviewOverrides: Partial> = { - api: 'manual', - 'billing-service': 'on', - docs: 'off', - examples: 'off', -}; - -const installations: PreviewInstallation[] = [ - { - id: 'acme', - account: 'acme', - access: 'all', - defaultModel: 'kilo-auto/frontier', - defaultPrReviews: 'on', - repositories: repositoryNames.map(name => ({ - id: `acme/${name}`, - name: `acme/${name}`, - private: !['cli', 'docs', 'examples', 'sdk'].includes(name), - model: repositoryOverrides[name] ?? null, - prReviews: reviewOverrides[name] ?? null, - })), - }, - { - id: 'acme-labs', - account: 'acme-labs', - access: 'selected', - defaultModel: 'anthropic/claude-sonnet-4.5', - defaultPrReviews: 'manual', - repositories: ['agent-experiments', 'benchmarks', 'playground', 'research'].map(name => ({ - id: `acme-labs/${name}`, - name: `acme-labs/${name}`, - private: name !== 'playground', - model: null, - prReviews: null, - })), - }, -]; - -const meta = { - title: 'Prototypes/GitHub Repository Customizations', - component: GitHubRepositoryCustomizationsPreview, - parameters: { - layout: 'fullscreen', - }, - args: { - scope: 'personal', - organizationName: 'Acme team', - models, - installations, - }, - argTypes: { - scope: { - control: 'inline-radio', - options: ['personal', 'organization'], - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Personal: Story = {}; - -export const Organization: Story = { - args: { - scope: 'organization', - }, -}; diff --git a/apps/web/src/components/integrations/GitHubIntegrationDetails.tsx b/apps/web/src/components/integrations/GitHubIntegrationDetails.tsx index 83692e0b55..e8b76b0d7b 100644 --- a/apps/web/src/components/integrations/GitHubIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/GitHubIntegrationDetails.tsx @@ -25,6 +25,7 @@ import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { buildGitHubInstallState } from './github-install-state'; import { useConfirm } from '@/components/ui/confirm'; import { OrganizationGitHubInstallations } from './OrganizationGitHubInstallations'; +import { InstallationCustomizations } from './GitHubRepositoryCustomizationsPreview'; type GitHubIntegrationDetailsProps = { organizationId?: string; @@ -40,6 +41,10 @@ type GitHubIntegrationDetailsProps = { existingPendingOrg?: string; appReturnPath?: string; onInstallationDetected?: () => void; + /** Reveals the Repository Customizations UI (default AI model / PR review + * mode plus per-repository overrides) behind the PER_REPO_SETTINGS flag. + * Computed server-side and threaded down since this is a client component. */ + perRepoSettingsEnabled?: boolean; }; /** @@ -233,7 +238,10 @@ export function GitHubIntegrationDetails(props: GitHubIntegrationDetailsProps) { {props.organizationId && !props.appReturnPath ? (
- +
@@ -274,6 +282,7 @@ function GitHubIntegrationDetailsContent({ pendingApproval, appReturnPath, onInstallationDetected, + perRepoSettingsEnabled, }: GitHubIntegrationDetailsProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -899,17 +908,19 @@ function GitHubIntegrationDetailsContent({
{/* Model Selection */} -
- -
+ {!perRepoSettingsEnabled && ( +
+ +
+ )} {/* Actions */}
@@ -1000,6 +1011,15 @@ function GitHubIntegrationDetailsContent({ + {isInstalled && installation && !isPendingApproval && perRepoSettingsEnabled && ( + + )} + {!organizationId ? ( diff --git a/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.test.ts b/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.test.ts index 71a73e3ced..54d49ce022 100644 --- a/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.test.ts +++ b/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.test.ts @@ -1,17 +1,17 @@ -import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { createRequire } from 'node:module'; import React, { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { - GitHubRepositoryCustomizationsPreview as PreviewComponent, - PreviewInstallation, -} from './GitHubRepositoryCustomizationsPreview'; +import type { GitHubRepositoryCustomizations as GitHubRepositoryCustomizationsComponent } from './GitHubRepositoryCustomizationsPreview'; import type { ModelOption } from '@/components/shared/ModelCombobox'; jest.mock('lucide-react', () => new Proxy({}, { get: () => () => null })); jest.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' '), })); +jest.mock('sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); jest.mock('@/components/ui/button', () => ({ Button: ({ children, @@ -77,10 +77,12 @@ jest.mock('@/components/ui/sheet', () => ({ jest.mock('@/components/ui/select', () => ({ Select: ({ value, + disabled, onValueChange, children, }: { value: string; + disabled?: boolean; onValueChange: (value: string) => void; children: React.ReactNode; }) => { @@ -93,6 +95,7 @@ jest.mock('@/components/ui/select', () => ({ { id, value, + disabled, onChange: (event: React.ChangeEvent) => onValueChange(event.currentTarget.value), }, @@ -113,6 +116,7 @@ jest.mock('@/components/shared/ModelCombobox', () => ({ models, label, triggerAriaLabel, + disabled, onValueChange, }: { id: string; @@ -120,6 +124,7 @@ jest.mock('@/components/shared/ModelCombobox', () => ({ models: ModelOption[]; label: string; triggerAriaLabel?: string; + disabled?: boolean; onValueChange: (value: string) => void; }) => createElement( @@ -127,6 +132,7 @@ jest.mock('@/components/shared/ModelCombobox', () => ({ { id, value, + disabled, 'aria-label': triggerAriaLabel ?? label, onChange: (event: React.ChangeEvent) => onValueChange(event.currentTarget.value), @@ -135,8 +141,147 @@ jest.mock('@/components/shared/ModelCombobox', () => ({ ), })); +type RepositoryCustomization = { + id: number; + name: string; + private: boolean; + model: string | null; + prReviews: 'on' | 'off' | null; +}; + +type RepositoryCustomizationsData = { + id: string; + account: string | null; + access: 'all' | 'selected'; + defaultModel: string; + defaultPrReviews: 'on' | 'off'; + repositories: RepositoryCustomization[]; +}; + +function createRepositoryCustomizationsData(): RepositoryCustomizationsData { + return { + id: 'first', + account: 'first', + access: 'all', + defaultModel: 'model-a', + defaultPrReviews: 'on', + repositories: [ + { + id: 1, + name: 'first/api', + private: true, + model: 'model-b', + prReviews: 'off', + }, + { + id: 2, + name: 'first/docs', + private: false, + model: 'model-a', + prReviews: 'off', + }, + { + id: 3, + name: 'first/billing', + private: true, + model: null, + prReviews: 'on', + }, + ...Array.from({ length: 9 }, (_, index) => ({ + id: 4 + index, + name: `first/repo-${index}`, + private: true, + model: null, + prReviews: null, + })), + ], + }; +} + +const models: ModelOption[] = [ + { id: 'model-a', name: 'Model A' }, + { id: 'model-b', name: 'Model B' }, +]; + +let mockRepositoryCustomizationsData: RepositoryCustomizationsData | undefined; +let mockListIntegrationsError: Error | undefined; +let mockGetRepositoryCustomizationsError: Error | undefined; +const mockUpdateInstallationSettingsMutateAsync = + jest.fn<(variables: unknown) => Promise<{ success: boolean; error?: string }>>(); +const mockUpdateRepositorySettingsMutateAsync = + jest.fn<(variables: unknown) => Promise<{ success: boolean; error?: string }>>(); +const mockListIntegrationsRefetch = jest.fn(); +const mockGetRepositoryCustomizationsRefetch = jest.fn(); +const mockSetQueryData = jest.fn( + ( + _queryKey: unknown, + updater: ( + current: RepositoryCustomizationsData | undefined + ) => RepositoryCustomizationsData | undefined + ) => { + mockRepositoryCustomizationsData = updater(mockRepositoryCustomizationsData); + } +); + +jest.mock('@/lib/trpc/utils', () => ({ + useTRPC: () => ({ + githubApps: { + listIntegrations: { + queryOptions: () => ({ __tag: 'listIntegrations' }), + }, + getRepositoryCustomizations: { + queryOptions: () => ({ __tag: 'getRepositoryCustomizations' }), + queryKey: (input: unknown) => ['githubApps.getRepositoryCustomizations', input], + }, + updateInstallationSettings: { + mutationOptions: () => ({ __tag: 'updateInstallationSettings' }), + }, + updateRepositorySettings: { + mutationOptions: () => ({ __tag: 'updateRepositorySettings' }), + }, + }, + }), +})); + +jest.mock('@tanstack/react-query', () => ({ + useQuery: (options: { __tag: string }) => { + if (options.__tag === 'listIntegrations') { + return { + data: mockListIntegrationsError ? undefined : [{ id: 'first' }], + isLoading: false, + isError: mockListIntegrationsError !== undefined, + error: mockListIntegrationsError, + refetch: mockListIntegrationsRefetch, + }; + } + if (options.__tag === 'getRepositoryCustomizations') { + return { + data: mockGetRepositoryCustomizationsError ? undefined : mockRepositoryCustomizationsData, + isLoading: false, + isError: mockGetRepositoryCustomizationsError !== undefined, + error: mockGetRepositoryCustomizationsError, + refetch: mockGetRepositoryCustomizationsRefetch, + }; + } + throw new Error(`Unexpected query tag: ${options.__tag}`); + }, + useMutation: (options: { __tag: string }) => { + if (options.__tag === 'updateInstallationSettings') { + return { mutateAsync: mockUpdateInstallationSettingsMutateAsync }; + } + if (options.__tag === 'updateRepositorySettings') { + return { mutateAsync: mockUpdateRepositorySettingsMutateAsync }; + } + throw new Error(`Unexpected mutation tag: ${options.__tag}`); + }, + useQueryClient: () => ({ setQueryData: mockSetQueryData }), +})); + type LinkedomModule = { - parseHTML: (html: string) => { window: Record; document: Document }; + parseHTML: (html: string) => { + window: Record; + document: Document; + }; }; function installDom() { @@ -168,71 +313,29 @@ function installDom() { }; } -const models: ModelOption[] = [ - { id: 'model-a', name: 'Model A' }, - { id: 'model-b', name: 'Model B' }, -]; - -function createInstallations(): PreviewInstallation[] { - return [ - { - id: 'first', - account: 'first', - access: 'all', - defaultModel: 'model-a', - defaultPrReviews: 'on', - repositories: [ - { - id: 'first/api', - name: 'first/api', - private: true, - model: 'model-b', - prReviews: 'manual', - }, - { - id: 'first/docs', - name: 'first/docs', - private: false, - model: 'model-a', - prReviews: 'off', - }, - { id: 'first/billing', name: 'first/billing', private: true, model: null, prReviews: 'on' }, - ...Array.from({ length: 9 }, (_, index) => ({ - id: `first/repo-${index}`, - name: `first/repo-${index}`, - private: true, - model: null, - prReviews: null, - })), - ], - }, - { - id: 'second', - account: 'second', - access: 'selected', - defaultModel: 'model-b', - defaultPrReviews: 'manual', - repositories: [ - { - id: 'second/research', - name: 'second/research', - private: true, - model: null, - prReviews: null, - }, - ], - }, - ]; +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; } -let Preview: typeof PreviewComponent; +let GitHubRepositoryCustomizations: typeof GitHubRepositoryCustomizationsComponent; -beforeAll(async () => { - ({ GitHubRepositoryCustomizationsPreview: Preview } = - await import('./GitHubRepositoryCustomizationsPreview')); +beforeEach(async () => { + jest.clearAllMocks(); + mockRepositoryCustomizationsData = createRepositoryCustomizationsData(); + mockListIntegrationsError = undefined; + mockGetRepositoryCustomizationsError = undefined; + mockUpdateInstallationSettingsMutateAsync.mockResolvedValue({ + success: true, + }); + mockUpdateRepositorySettingsMutateAsync.mockResolvedValue({ success: true }); + ({ GitHubRepositoryCustomizations } = await import('./GitHubRepositoryCustomizationsPreview')); }); -describe('GitHub repository customizations preview', () => { +describe('GitHubRepositoryCustomizations (live)', () => { let root: Root | undefined; let cleanup: (() => void) | undefined; @@ -243,17 +346,20 @@ describe('GitHub repository customizations preview', () => { cleanup = undefined; }); - function render(scope: 'personal' | 'organization' = 'personal') { + function render() { const dom = installDom(); cleanup = dom.cleanup; root = createRoot(dom.container); - const installations = createInstallations(); - act(() => + act(() => { root?.render( - createElement(Preview, { scope, organizationName: 'Test team', installations, models }) - ) - ); - return { container: dom.container, installations }; + createElement(GitHubRepositoryCustomizations, { + scope: 'personal', + organizationName: 'Test team', + models, + }) + ); + }); + return { container: dom.container }; } function find(container: ParentNode, selector: string): T { @@ -270,30 +376,34 @@ describe('GitHub repository customizations preview', () => { return element; } - function click(element: HTMLElement) { - act(() => { + async function click(element: HTMLElement) { + await act(async () => { element.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); }); } - function select(container: ParentNode, id: string, value: string) { + async function select(container: ParentNode, id: string, value: string) { const element = find(container, `select[id="${id}"]`); const options = Array.from(element.options); const selected = options.find(option => option.value === value); if (!selected) throw new Error(`Missing option: ${value}`); - act(() => { + await act(async () => { for (const option of options) option.selected = false; selected.selected = true; element.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); }); } - function search(container: ParentNode, value: string) { - const element = find(container, 'input[type="search"]'); - act(() => { - element.value = value; - element.dispatchEvent(new Event('input', { bubbles: true })); - }); + function row(container: ParentNode, name: string) { + const element = Array.from(container.querySelectorAll('tbody tr')).find( + candidate => candidate.querySelector('[title]')?.getAttribute('title') === name + ); + if (!element) throw new Error(`Missing repository row: ${name}`); + return element; } function chooseModelSource(container: ParentNode, custom: boolean) { @@ -306,153 +416,119 @@ describe('GitHub repository customizations preview', () => { }); } - function row(container: ParentNode, name: string) { - const element = Array.from(container.querySelectorAll('tbody tr')).find( - candidate => candidate.querySelector('[title]')?.getAttribute('title') === name - ); - if (!element) throw new Error(`Missing repository row: ${name}`); - return element; - } - - it('shows only the personal installation, paginates all-access repositories, and searches across pages', () => { + it('renders installation defaults and repository overrides from the query', () => { const { container } = render(); - expect(container.querySelectorAll('tbody tr')).toHaveLength(10); expect(container.textContent).toContain('All repositories'); - expect(container.textContent).not.toContain('second'); - expect(container.querySelector('[id$="configuration-filter"]')).toBeNull(); - expect(container.querySelector('[id$="model-filter"]')).toBeNull(); - expect(button(container, 'Previous page').disabled).toBe(true); - click(button(container, 'Next page')); - expect(container.querySelectorAll('tbody tr')).toHaveLength(2); - expect(container.textContent).toContain('11–12 of 12 repositories'); - expect(button(container, 'Next page').disabled).toBe(true); - click(button(container, 'Previous page')); - search(container, 'FIRST/REPO-8'); - expect(container.querySelectorAll('tbody tr')).toHaveLength(1); - expect(row(container, 'first/repo-8')).toBeDefined(); - click(button(container, 'Clear search')); - expect(container.textContent).toContain('1–10 of 12 repositories'); + expect(row(container, 'first/api').textContent).toContain('PR reviews: Off'); + expect(row(container, 'first/repo-0').textContent).toContain('Using integration defaults'); }); - it('searches effective models and review settings, including inherited values, and clears empty results', () => { + it('saves a repository override and patches the cache with the persisted value', async () => { const { container } = render(); - search(container, 'model-a'); - expect(container.textContent).toContain('1–10 of 11 repositories'); - expect(row(container, 'first/repo-0').textContent).toContain('Using integration defaults'); - expect(row(container, 'first/docs').textContent).toContain('Model: Model A'); - search(container, 'reviews: manual'); - expect(container.querySelectorAll('tbody tr')).toHaveLength(1); - expect(row(container, 'first/api').textContent).toContain('PR reviews: Manual (@mention)'); - search(container, 'not-a-repository'); - expect(container.querySelectorAll('tbody tr')).toHaveLength(0); - expect(container.textContent).toContain('No repositories match your search'); - click(button(container, 'Clear search')); - expect(find(container, 'input[type="search"]').value).toBe(''); - expect(container.querySelectorAll('tbody tr')).toHaveLength(10); + await click(button(container, 'Edit first/repo-0')); + const editor = find(container, '[role="dialog"]'); + chooseModelSource(editor, true); + await select(editor, '4-custom-model', 'model-b'); + await click(button(editor, 'Save changes')); + + expect(mockUpdateRepositorySettingsMutateAsync).toHaveBeenCalledWith({ + organizationId: undefined, + integrationId: 'first', + repositoryId: 4, + settings: { modelSlug: 'model-b', prReviewMode: null }, + }); + expect(row(container, 'first/repo-0').textContent).toContain('Model: Model B'); }); - it('discards cancelled changes and saves independent model and review overrides', () => { + it('disables the default model control while saving and reverts it if the save fails', async () => { + const deferred = createDeferred<{ success: boolean }>(); + mockUpdateInstallationSettingsMutateAsync.mockReturnValue(deferred.promise); const { container } = render(); - click(button(container, 'Edit first/repo-0')); - let editor = find(container, '[role="dialog"]'); - expect(button(editor, 'Save changes').disabled).toBe(true); - chooseModelSource(editor, true); - select(editor, 'first/repo-0-custom-model', 'model-b'); - select(editor, 'first/repo-0-reviews', 'manual'); - click(button(editor, 'Cancel')); - expect(container.querySelector('[role="dialog"]')).toBeNull(); - expect(row(container, 'first/repo-0').textContent).toContain('Using integration defaults'); - click(button(container, 'Edit first/repo-0')); - editor = find(container, '[role="dialog"]'); - expect(button(editor, 'Save changes').disabled).toBe(true); - chooseModelSource(editor, true); - select(editor, 'first/repo-0-custom-model', 'model-b'); - select(editor, 'first/repo-0-reviews', 'manual'); - click(button(editor, 'Save changes')); - expect(row(container, 'first/repo-0').textContent).toContain( - 'Model: Model B · PR reviews: Manual (@mention)' - ); - click(button(container, 'Edit first/repo-0')); - editor = find(container, '[role="dialog"]'); - expect(find(editor, 'select[id="first/repo-0-custom-model"]').value).toBe( + + await select(container, 'first-default-model', 'model-b'); + expect(find(container, 'select[id="first-default-model"]').value).toBe( 'model-b' ); - expect(find(editor, 'select[id="first/repo-0-reviews"]').value).toBe( - 'manual' + expect(find(container, 'select[id="first-default-model"]').disabled).toBe( + true ); - expect(button(editor, 'Save changes').disabled).toBe(true); - }); - it('follows new defaults for inherited values while keeping equal-value overrides pinned', () => { - const { container } = render(); - select(container, 'first-default-model', 'model-b'); - select(container, 'first-default-reviews', 'off'); - click(button(container, 'Edit first/repo-0')); - let editor = find(container, '[role="dialog"]'); - expect(editor.textContent).toContain('Model B'); - expect(find(editor, 'select[id="first/repo-0-reviews"]').value).toBe( - 'default' + await act(async () => { + deferred.resolve({ success: false }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(find(container, 'select[id="first-default-model"]').disabled).toBe( + false ); - expect(editor.textContent).toContain('Use integration default — Off'); - click(button(editor, 'Cancel')); - click(button(container, 'Edit first/docs')); - editor = find(container, '[role="dialog"]'); - expect(find(editor, 'select[id="first/docs-custom-model"]').value).toBe( + expect(find(container, 'select[id="first-default-model"]').value).toBe( 'model-a' ); - click(button(editor, 'Cancel')); - click(button(container, 'Edit first/billing')); - editor = find(container, '[role="dialog"]'); - expect(find(editor, 'select[id="first/billing-reviews"]').value).toBe('on'); - click(button(editor, 'Cancel')); - expect(row(container, 'first/billing').textContent).toContain('PR reviews: On'); + expect(mockSetQueryData).not.toHaveBeenCalled(); }); - it('removes overrides when returning to defaults and updates active search results', () => { + it('reports a repository policy rejection without applying the edit', async () => { + mockUpdateRepositorySettingsMutateAsync.mockResolvedValue({ + success: false, + error: 'Model is not allowed by organization policy', + }); const { container } = render(); - select(container, 'first-default-model', 'model-b'); - search(container, 'docs Model A'); - click(button(container, 'Edit first/docs')); + await click(button(container, 'Edit first/repo-0')); const editor = find(container, '[role="dialog"]'); - chooseModelSource(editor, false); - select(editor, 'first/docs-reviews', 'default'); - click(button(editor, 'Save changes')); - expect(container.querySelectorAll('tbody tr')).toHaveLength(0); - click(button(container, 'Clear search')); - expect(row(container, 'first/docs').textContent).toContain('Using integration defaults'); - expect(container.textContent).toContain('2 customized'); + chooseModelSource(editor, true); + await select(editor, '4-custom-model', 'model-b'); + await click(button(editor, 'Save changes')); + + expect(row(container, 'first/repo-0').textContent).toContain('Using integration defaults'); + expect(mockSetQueryData).not.toHaveBeenCalled(); + const { toast } = jest.requireMock<{ toast: { error: jest.Mock } }>('sonner'); + expect(toast.error).toHaveBeenCalledWith('Model is not allowed by organization policy'); }); - it('isolates organization settings and resets local changes without mutating fixtures', () => { - const { container, installations } = render('organization'); - const original = structuredClone(installations); - expect(container.textContent).toContain('1 selected repositories'); - expect(container.querySelector('[aria-label="Edit second/research"]')).toBeNull(); - click(button(container, 'Toggle settings for second')); - select(container, 'first-default-model', 'model-b'); - select(container, 'first-default-reviews', 'off'); - expect(find(container, 'select[id="second-default-model"]').value).toBe( - 'model-b' - ); - expect(find(container, 'select[id="second-default-reviews"]').value).toBe( - 'manual' - ); - select(container, 'second-default-model', 'model-a'); - expect(find(container, 'select[id="first-default-model"]').value).toBe( - 'model-b' - ); - click(button(container, 'Edit second/research')); + it('re-enables the Edit button once a repository save settles', async () => { + const deferred = createDeferred<{ success: boolean }>(); + mockUpdateRepositorySettingsMutateAsync.mockReturnValue(deferred.promise); + const { container } = render(); + await click(button(container, 'Edit first/repo-0')); const editor = find(container, '[role="dialog"]'); - select(editor, 'second/research-reviews', 'off'); - click(button(editor, 'Save changes')); - expect(row(container, 'second/research').textContent).toContain('PR reviews: Off'); - expect(row(container, 'first/api').textContent).toContain('PR reviews: Manual (@mention)'); - click(button(container, 'Reset preview')); - expect(find(container, 'select[id="first-default-model"]').value).toBe( - 'model-a' + chooseModelSource(editor, true); + await select(editor, '4-custom-model', 'model-b'); + await click(button(editor, 'Save changes')); + + expect(button(container, 'Edit first/repo-0').disabled).toBe(true); + + await act(async () => { + deferred.resolve({ success: true }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(button(container, 'Edit first/repo-0').disabled).toBe(false); + }); + + it('shows a retryable error instead of an endless loading state when the customizations query fails', async () => { + mockGetRepositoryCustomizationsError = new Error('Request failed'); + const { container } = render(); + + expect(container.textContent).not.toContain('Loading repository customizations…'); + expect(container.textContent).toContain( + 'Couldn’t load repository customizations: Request failed' ); - click(button(container, 'Toggle settings for second')); - expect(row(container, 'second/research').textContent).toContain('Using integration defaults'); - expect(installations).toEqual(original); + + await click(button(container, 'Retry')); + expect(mockGetRepositoryCustomizationsRefetch).toHaveBeenCalledTimes(1); + }); + + it('shows a retryable error for the installations list when it fails to load', async () => { + mockListIntegrationsError = new Error('Request failed'); + const { container } = render(); + + expect(container.textContent).not.toContain('Loading installations…'); + expect(container.textContent).toContain( + 'Couldn’t load GitHub App installations: Request failed' + ); + expect(container.textContent).not.toContain('No GitHub App installations found.'); + + await click(button(container, 'Retry')); + expect(mockListIntegrationsRefetch).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.tsx b/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.tsx index e0042eccda..b97f4b174e 100644 --- a/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.tsx +++ b/apps/web/src/components/integrations/GitHubRepositoryCustomizationsPreview.tsx @@ -8,9 +8,11 @@ import { Github, LockKeyhole, Pencil, - RotateCcw, Search, } from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useTRPC } from '@/lib/trpc/utils'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -42,19 +44,21 @@ import { TableRow, } from '@/components/ui/table'; import { cn } from '@/lib/utils'; -export type PreviewReviewMode = 'on' | 'off' | 'manual'; + +// Automatic PR review dispatch only, for now — see github-repository-settings.ts. +// Manual/@mention-triggered reviews are a separate, later change. +type PreviewReviewMode = 'on' | 'off'; const reviewModes: { value: PreviewReviewMode; label: string }[] = [ { value: 'on', label: 'On' }, { value: 'off', label: 'Off' }, - { value: 'manual', label: 'Manual (@mention)' }, ]; function reviewModeName(value: PreviewReviewMode) { return reviewModes.find(mode => mode.value === value)?.label ?? value; } -export type PreviewRepository = { +type PreviewRepository = { id: string; name: string; private: boolean; @@ -62,7 +66,7 @@ export type PreviewRepository = { prReviews: PreviewReviewMode | null; }; -export type PreviewInstallation = { +type PreviewInstallation = { id: string; account: string; access: 'all' | 'selected'; @@ -88,86 +92,38 @@ function repositoryCustomizationSummary( const PAGE_SIZE = 10; -type PreviewProps = { - scope: 'personal' | 'organization'; - organizationName: string; - installations: PreviewInstallation[]; - models: ModelOption[]; -}; - -export function GitHubRepositoryCustomizationsPreview({ - scope, - organizationName, - installations, - models, -}: PreviewProps) { - const [revision, setRevision] = useState(0); - - return ( -
-
-
-
- UI preview - Mock data only. Changes reset when you reload. -
- -
-
-
-
-

- {scope === 'organization' ? organizationName : 'Personal account'} - - Integrations - - GitHub -

-
-

GitHub integration

-

- {scope === 'organization' - ? 'Manage connected GitHub organizations and how Kilo responds in their repositories.' - : 'Manage your connected GitHub account and how Kilo responds in your repositories.'} -

-
-
-
- {installations - .slice(0, scope === 'personal' ? 1 : undefined) - .map((installation, index) => ( - - ))} -
-
-
- ); -} - -function InstallationCustomizations({ - installation, +export function InstallationCustomizations({ + integrationId, + organizationId, models, initiallyOpen, }: { - installation: PreviewInstallation; + integrationId: string; + organizationId?: string; models: ModelOption[]; initiallyOpen: boolean; }) { - const [defaultModel, setDefaultModel] = useState(installation.defaultModel); - const [defaultPrReviews, setDefaultPrReviews] = useState(installation.defaultPrReviews); - const [repositories, setRepositories] = useState(installation.repositories); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const queryInput = { organizationId, integrationId }; + const queryKey = trpc.githubApps.getRepositoryCustomizations.queryKey(queryInput); + const { data, isLoading, isError, error, refetch } = useQuery( + trpc.githubApps.getRepositoryCustomizations.queryOptions(queryInput) + ); + const updateInstallationSettings = useMutation( + trpc.githubApps.updateInstallationSettings.mutationOptions() + ); + const updateRepositorySettings = useMutation( + trpc.githubApps.updateRepositorySettings.mutationOptions() + ); + + const [pendingModel, setPendingModel] = useState(null); + const [pendingPrReviews, setPendingPrReviews] = useState(null); + const [savingDefaults, setSavingDefaults] = useState(false); + const [pendingRepositoryOverrides, setPendingRepositoryOverrides] = useState< + Record> + >({}); + const [savingRepositoryId, setSavingRepositoryId] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [editingRepository, setEditingRepository] = useState(null); @@ -175,6 +131,123 @@ function InstallationCustomizations({ const editTrigger = useRef(null); const searchInput = useRef(null); + if (isLoading) { + return ( + Loading repository customizations… + ); + } + + if (isError || !data) { + return ( + +

+ Couldn’t load repository customizations + {error instanceof Error && error.message ? `: ${error.message}` : '.'} +

+ +
+ ); + } + + const installation: PreviewInstallation = { + id: data.id, + account: data.account ?? integrationId, + access: data.access === 'all' ? 'all' : 'selected', + defaultModel: data.defaultModel, + defaultPrReviews: data.defaultPrReviews, + repositories: data.repositories.map(repository => ({ + id: String(repository.id), + name: repository.name, + private: repository.private, + model: repository.model, + prReviews: repository.prReviews, + })), + }; + + async function saveDefaults(settings: { model?: string; prReviews?: PreviewReviewMode }) { + try { + const result = await updateInstallationSettings.mutateAsync({ + organizationId, + integrationId, + settings: { + modelSlug: settings.model, + prReviewMode: settings.prReviews, + }, + }); + if (!result.success) { + toast.error(result.error ?? 'Failed to update default settings'); + return false; + } + queryClient.setQueryData(queryKey, current => + current + ? { + ...current, + defaultModel: settings.model ?? current.defaultModel, + defaultPrReviews: settings.prReviews ?? current.defaultPrReviews, + } + : current + ); + return true; + } catch (error) { + toast.error('Failed to update default settings', { + description: error instanceof Error ? error.message : undefined, + }); + return false; + } + } + + async function saveRepositorySettings( + repositoryId: string, + settings: Pick + ) { + try { + const result = await updateRepositorySettings.mutateAsync({ + organizationId, + integrationId, + repositoryId: Number(repositoryId), + settings: { + modelSlug: settings.model, + prReviewMode: settings.prReviews, + }, + }); + if (!result.success) { + toast.error(result.error ?? 'Failed to update repository settings'); + return false; + } + queryClient.setQueryData(queryKey, current => + current + ? { + ...current, + repositories: current.repositories.map(repository => + String(repository.id) === repositoryId + ? { + ...repository, + model: settings.model, + prReviews: settings.prReviews, + } + : repository + ), + } + : current + ); + return true; + } catch (error) { + toast.error('Failed to update repository settings', { + description: error instanceof Error ? error.message : undefined, + }); + return false; + } + } + + const defaultModel = pendingModel ?? installation.defaultModel; + const defaultPrReviews = pendingPrReviews ?? installation.defaultPrReviews; + const repositories = installation.repositories.map(repository => { + const pending = pendingRepositoryOverrides[repository.id]; + return pending ? { ...repository, ...pending } : repository; + }); + const customizedCount = repositories.filter( repository => repositoryCustomizationSummary(models, repository).length > 0 ).length; @@ -250,12 +323,20 @@ function InstallationCustomizations({ triggerAriaLabel={`Default AI model for ${installation.account}`} models={models} value={defaultModel} - onValueChange={model => { - setDefaultModel(model); + disabled={savingDefaults} + onValueChange={async model => { + setPendingModel(model); + setSavingDefaults(true); setPage(1); setAnnouncement( - `Default updated to ${modelName(models, model)} in this preview. Custom models are unchanged.` + `Default updated to ${modelName(models, model)}. Custom models are unchanged.` ); + const success = await saveDefaults({ model }); + setPendingModel(null); + setSavingDefaults(false); + if (!success) { + setAnnouncement(`Couldn't update the default model. Please try again.`); + } }} />
@@ -265,20 +346,26 @@ function InstallationCustomizations({ Default pull request reviews

- Review pull requests automatically, only when @mentioned, or not at all. - Repository overrides are unaffected. + Review pull requests automatically on new pull requests, or turn reviews off + entirely. Repository overrides are unaffected.

{ + disabled={savingDefaults} + onValueChange={async mode => { if (mode === null) return; - setDefaultPrReviews(mode); + setPendingPrReviews(mode); + setSavingDefaults(true); setPage(1); - setAnnouncement( - `Default PR reviews updated to ${reviewModeName(mode)} in this preview.` - ); + setAnnouncement(`Default PR reviews updated to ${reviewModeName(mode)}.`); + const success = await saveDefaults({ prReviews: mode }); + setPendingPrReviews(null); + setSavingDefaults(false); + if (!success) { + setAnnouncement(`Couldn't update default PR reviews. Please try again.`); + } }} /> @@ -375,6 +462,7 @@ function InstallationCustomizations({ size="sm" className="min-h-11 px-2 sm:min-h-8" aria-label={`Edit ${repository.name}`} + disabled={savingRepositoryId === repository.id} onClick={event => { editTrigger.current = event.currentTarget; setEditingRepository(repository); @@ -474,16 +562,25 @@ function InstallationCustomizations({ defaultPrReviews={defaultPrReviews} account={installation.account} onCancel={() => setEditingRepository(null)} - onSave={settings => { - setRepositories(current => - current.map(repository => - repository.id === editingRepository.id - ? { ...repository, ...settings } - : repository - ) - ); - setAnnouncement(`${editingRepository.name} updated in this preview.`); + onSave={async settings => { + const repositoryId = editingRepository.id; + const repositoryName = editingRepository.name; + setPendingRepositoryOverrides(current => ({ + ...current, + [repositoryId]: settings, + })); + setSavingRepositoryId(repositoryId); + setAnnouncement(`${repositoryName} updated.`); setEditingRepository(null); + const success = await saveRepositorySettings(repositoryId, settings); + setPendingRepositoryOverrides(current => { + const { [repositoryId]: _removed, ...rest } = current; + return rest; + }); + setSavingRepositoryId(null); + if (!success) { + setAnnouncement(`Couldn't update ${repositoryName}. Please try again.`); + } }} /> )} @@ -519,16 +616,19 @@ function ReviewModeSelect({ id, value, defaultMode, + disabled, onValueChange, }: { id: string; value: PreviewReviewMode | null; defaultMode?: PreviewReviewMode; + disabled?: boolean; onValueChange: (value: PreviewReviewMode | null) => void; }) { return (