Commit 1c77ff82 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska 2️⃣
Browse files

feat: Get repositories from the LS

parent 2b74c781
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ export enum FeatureFlag {
  FixWithDuoQuickChatCodeActions = 'fixWithDuoQuickChatCodeActions',
  LsCredentialsSync = 'lsCredentialsSync',
  FormatEdits = 'formatEdits',
  LsRepositories = 'useLanguageServerRepositories',
}

// Set the feature flag default value here
@@ -37,6 +38,7 @@ export const FEATURE_FLAGS_DEFAULT_VALUES = {
  [FeatureFlag.FixWithDuoQuickChatCodeActions]: true,
  [FeatureFlag.LsCredentialsSync]: true,
  [FeatureFlag.FormatEdits]: false,
  [FeatureFlag.LsRepositories]: false,
};

// PLEASE NOTE: We can only query 20 flags at a time so this list shouldn't grow past that.
+13 −0
Original line number Diff line number Diff line
@@ -38,6 +38,7 @@ import { LanguageClientWrapper, LanguageClientWrapperImpl } from './language_cli
import { LanguageServerFeatureStateProvider } from './language_server_feature_state_provider';
import { FileSnapshotProvider } from './file_snapshot_provider';
import { GET_DIAGNOSTICS_REQUEST_METHOD } from './document_quality_handler';
import { RepositoryClient } from './repository_client';

jest.mock('../code_suggestions/gitlab_platform_manager_for_code_suggestions');
jest.mock('../log'); // disable logging in tests
@@ -66,6 +67,8 @@ describe('LanguageClientWrapper', () => {

  let fileSnapshotProvider: FileSnapshotProvider;

  let repositoryClient: RepositoryClient;

  let wrapper: LanguageClientWrapper;

  const createWrapper = (
@@ -78,6 +81,7 @@ describe('LanguageClientWrapper', () => {
      mockFeatureStateManager: LanguageServerFeatureStateProvider;
      mockTerminalManager: TerminalManager;
      mockFileSnapshotProvider: FileSnapshotProvider;
      mockRepositoryClient: RepositoryClient;
    }> = {},
  ) => {
    const {
@@ -89,6 +93,7 @@ describe('LanguageClientWrapper', () => {
      mockFeatureStateManager = languageServerFeatureStateProvider,
      mockTerminalManager = terminalManager,
      mockFileSnapshotProvider = fileSnapshotProvider,
      mockRepositoryClient = repositoryClient,
    } = options;

    return new LanguageClientWrapperImpl(
@@ -100,6 +105,7 @@ describe('LanguageClientWrapper', () => {
      mockFeatureStateManager,
      mockTerminalManager,
      mockFileSnapshotProvider,
      mockRepositoryClient,
    );
  };

@@ -138,6 +144,13 @@ describe('LanguageClientWrapper', () => {
    });

    fileSnapshotProvider = createFakePartial<FileSnapshotProvider>({});

    repositoryClient = createFakePartial<RepositoryClient>({
      setRequestFunction: jest.fn(),
      handleRepositoriesChanged: jest.fn(),
      getRepositories: jest.fn(),
      onRepositoriesChanged: jest.fn(),
    });
  });

  afterEach(() => {
+20 −0
Original line number Diff line number Diff line
@@ -14,6 +14,9 @@ import {
  GitDiffRequest,
  FeatureStateChangeNotificationType,
  ClientConfig,
  RepositoriesChangedNotificationType,
  GetRepositoriesResponse,
  RepositoryEndpoints,
} from '@gitlab-org/gitlab-lsp';
import {
  BaseLanguageClient,
@@ -57,6 +60,7 @@ import { SaveFileMiddleware } from './save_file_middleware';
import { DiffMiddleware } from './diff_middleware';
import { FileSnapshotProvider } from './file_snapshot_provider';
import { FormatEditsMiddleware } from './format_edits_middleware';
import { RepositoryClient } from './repository_client';

// Interface IDs for dependency injection
export const BaseLanguageClientId = createInterfaceId<BaseLanguageClient>('BaseLanguageClient');
@@ -93,6 +97,7 @@ export const LanguageClientWrapper =
  LanguageServerFeatureStateProvider,
  TerminalManager,
  FileSnapshotProvider,
  RepositoryClient,
])
export class LanguageClientWrapperImpl implements LanguageClientWrapper {
  #client: BaseLanguageClient;
@@ -117,6 +122,8 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper {

  #terminalManager: TerminalManager;

  #repositoryClient: RepositoryClient;

  constructor(
    client: BaseLanguageClient,
    suggestionsManager: GitLabPlatformManagerForCodeSuggestions,
@@ -126,6 +133,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper {
    languageServerFeatureStateProvider: LanguageServerFeatureStateProvider,
    terminalManager: TerminalManager,
    fileSnapshotProvider: FileSnapshotProvider,
    repositoryClient: RepositoryClient,
  ) {
    this.#client = client;
    this.#suggestionsManager = suggestionsManager;
@@ -136,6 +144,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper {
    this.#terminalManager = terminalManager;
    this.#fileSnapshotProvider = fileSnapshotProvider;
    this.#documentQualityHandler = new DocumentQualityHandler();
    this.#repositoryClient = repositoryClient;
    this.#subscriptions.push(this.#documentQualityHandler);
  }

@@ -162,6 +171,17 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper {
      }),
    );

    this.#repositoryClient.setRequestFunction(() =>
      this.#client.sendRequest<GetRepositoriesResponse>(RepositoryEndpoints.GET_REPOSITORIES),
    );

    this.#client.onNotification(
      RepositoriesChangedNotificationType,
      (change: GetRepositoriesResponse) => {
        this.#repositoryClient.handleRepositoriesChanged(change);
      },
    );

    this.#terminalManager.setupRequests(this.#client);

    this.#subscriptions.push(this.#terminalManager);
+149 −0
Original line number Diff line number Diff line
import {
  GetRepositoriesResponse,
  RepositoryState,
  SingleProjectRepository,
} from '@gitlab-org/gitlab-lsp';
import { createFakePartial } from '../test_utils/create_fake_partial';
import { RepositoryClientImpl } from './repository_client';

describe('RepositoryClient', () => {
  let mockSendRequest: jest.Mock;
  let repositoryClient: RepositoryClientImpl;

  const mockRepository = createFakePartial<SingleProjectRepository>({
    type: 'single',
    repository: {
      rootFsPath: '/path/to/repo',
      folderName: 'test-repo',
    },
  });

  const mockRepositories: RepositoryState[] = [mockRepository];

  beforeEach(() => {
    mockSendRequest = jest.fn();
    repositoryClient = new RepositoryClientImpl();
    repositoryClient.setRequestFunction(mockSendRequest);
  });

  describe('setRequestFunction', () => {
    it('should set the request function', () => {
      const newMockSendRequest = jest.fn();
      repositoryClient.setRequestFunction(newMockSendRequest);

      expect(() => repositoryClient.setRequestFunction(newMockSendRequest)).not.toThrow();
    });
  });

  describe('getRepositories', () => {
    it('should return cached repositories if available', async () => {
      repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories });

      const result = await repositoryClient.getRepositories();

      expect(result).toEqual(mockRepositories);
      expect(mockSendRequest).not.toHaveBeenCalled();
    });

    it('should fetch repositories from language server if cache is empty', async () => {
      const mockResponse: GetRepositoriesResponse = {
        repositories: mockRepositories,
      };

      mockSendRequest.mockResolvedValue(mockResponse);

      const result = await repositoryClient.getRepositories();

      expect(mockSendRequest).toHaveBeenCalledWith();
      expect(result).toEqual(mockRepositories);
    });

    it('should return empty array if language server request fails', async () => {
      mockSendRequest.mockRejectedValue(new Error('Request failed'));

      const result = await repositoryClient.getRepositories();

      expect(result).toEqual([]);
    });

    it('should cache repositories after successful request', async () => {
      const mockResponse: GetRepositoriesResponse = {
        repositories: mockRepositories,
      };

      mockSendRequest.mockResolvedValue(mockResponse);

      // First call should make request
      await repositoryClient.getRepositories();
      expect(mockSendRequest).toHaveBeenCalledTimes(1);

      // Second call should use cache
      const result = await repositoryClient.getRepositories();
      expect(mockSendRequest).toHaveBeenCalledTimes(1);
      expect(result).toEqual(mockRepositories);
    });

    it('should return empty array if request function is not set', async () => {
      const newRepositoryClient = new RepositoryClientImpl();

      const result = await newRepositoryClient.getRepositories();

      expect(result).toEqual([]);
    });
  });

  describe('handleRepositoriesChanged', () => {
    it('should emit event when repositories change', () => {
      const listener = jest.fn();
      repositoryClient.onRepositoriesChanged(listener);

      repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories });

      expect(listener).toHaveBeenCalledWith(mockRepositories);
    });

    it('should update cached repositories when notification is received', async () => {
      const newRepository: SingleProjectRepository = {
        ...mockRepository,
        repository: {
          ...mockRepository.repository,
          rootFsPath: '/path/to/new-repo',
          folderName: 'new-repo',
        },
      };
      const newRepositories: RepositoryState[] = [newRepository];

      repositoryClient.handleRepositoriesChanged({ repositories: newRepositories });

      const result = await repositoryClient.getRepositories();

      expect(result).toEqual(newRepositories);
      expect(mockSendRequest).not.toHaveBeenCalled();
    });

    it('should not emit duplicate events for same repositories', () => {
      const listener = jest.fn();
      repositoryClient.onRepositoriesChanged(listener);

      // First notification
      repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories });
      expect(listener).toHaveBeenCalledTimes(1);

      // Same repositories - should not emit again due to diffEmitter
      repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories });
      expect(listener).toHaveBeenCalledTimes(1);

      // Different repositories - should emit
      const differentRepository: SingleProjectRepository = {
        ...mockRepository,
        repository: {
          ...mockRepository.repository,
          rootFsPath: '/different/path',
        },
      };
      const differentRepositories = [differentRepository];
      repositoryClient.handleRepositoriesChanged({ repositories: differentRepositories });
      expect(listener).toHaveBeenCalledTimes(2);
    });
  });
});
+55 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import { GetRepositoriesResponse, RepositoryState } from '@gitlab-org/gitlab-lsp';
import { createInterfaceId } from '@gitlab/needle';
import { log } from '../log';
import { diffEmitter } from '../utils/diff_emitter';

export interface RepositoryClient {
  getRepositories(): Promise<RepositoryState[]>;
  onRepositoriesChanged: vscode.Event<RepositoryState[]>;
  handleRepositoriesChanged(change: GetRepositoriesResponse): void;
  setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>): void;
}

export const RepositoryClient = createInterfaceId<RepositoryClient>('RepositoryClient');

export class RepositoryClientImpl implements RepositoryClient {
  #emitter = diffEmitter(new vscode.EventEmitter<RepositoryState[]>());

  #cachedRepositories: RepositoryState[] = [];

  #sendRequest?: (params?: unknown) => Promise<GetRepositoriesResponse>;

  readonly onRepositoriesChanged = this.#emitter.event;

  setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>) {
    this.#sendRequest = requestFn;
  }

  handleRepositoriesChanged(change: GetRepositoriesResponse) {
    const repositories = change.repositories ?? [];
    this.#cachedRepositories = repositories;
    this.#emitter.fire(repositories);
  }

  async getRepositories(): Promise<RepositoryState[]> {
    if (this.#cachedRepositories.length > 0) {
      return this.#cachedRepositories;
    }

    if (!this.#sendRequest) {
      log.warn('Repository client not initialized with request function');
      return [];
    }

    try {
      const result = await this.#sendRequest();
      const repositories = result.repositories ?? [];
      this.#cachedRepositories = repositories;
      return repositories;
    } catch (error) {
      log.error('Failed to get repositories:', error);
      return [];
    }
  }
}
Loading