diff --git a/package-lock.json b/package-lock.json index 4e2c6b5327..6cf57fea82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.5", + "@vscode/copilot-api": "^0.2.6", "@vscode/extension-telemetry": "^1.2.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.5", @@ -6429,9 +6429,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.2.5.tgz", - "integrity": "sha512-FITunwQI7JNXOFHikgMt6y2TKEjro14CCJbYjieLwvXkv+3t6tDxI0SEU+W4z0VxMMp4g3uCOqk8+WZa1LQaBw==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.2.6.tgz", + "integrity": "sha512-I5CTQMaV1iIIczA2sew3E85O0qYvwGZhgQtzTxdBk7wclhLcDTVXoz/oAcY1vc5gTl+SzlwM720NDNt/5GpLZQ==", "license": "SEE LICENSE" }, "node_modules/@vscode/debugadapter": { diff --git a/package.json b/package.json index 10b8c4ae43..d2eeb4f426 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ "onUri", "onFileSystem:ccreq", "onFileSystem:ccsettings", - "onCustomAgentsProvider" + "onCustomAgentsProvider", + "onInstructionsProvider" ], "main": "./dist/extension", "l10n": "./l10n", @@ -2502,6 +2503,11 @@ "default": true, "description": "%github.copilot.config.customAgents.showOrganizationAndEnterpriseAgents%" }, + "github.copilot.chat.customInstructions.useOrganizationInstructions": { + "type": "boolean", + "default": true, + "description": "%github.copilot.config.customInstructions.useOrganizationInstructions%" + }, "github.copilot.chat.agent.currentEditorContext.enabled": { "type": "boolean", "default": true, @@ -5227,7 +5233,7 @@ "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.5", + "@vscode/copilot-api": "^0.2.6", "@vscode/extension-telemetry": "^1.2.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.5", diff --git a/package.nls.json b/package.nls.json index fef8de49f3..6571dd546f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -346,6 +346,7 @@ "github.copilot.config.agent.currentEditorContext.enabled": "When enabled, Copilot will include the name of the current active editor in the context for agent mode.", "github.copilot.config.customInstructionsInSystemMessage": "When enabled, custom instructions and mode instructions will be appended to the system message instead of a user message.", "github.copilot.config.customAgents.showOrganizationAndEnterpriseAgents": "Enable custom agents from GitHub Enterprise and Organizations. When disabled, custom agents from your organization or enterprise will not be available in Copilot.", + "github.copilot.config.customInstructions.useOrganizationInstructions": "Enable custom instructions from GitHub Organizations. When disabled, custom instructions from your organization will not be available in Copilot.", "copilot.toolSet.editing.description": "Edit files in your workspace", "copilot.toolSet.read.description": "Read files in your workspace", "copilot.toolSet.search.description": "Search files in your workspace", diff --git a/src/extension/agents/vscode-node/organizationInstructionsContrib.ts b/src/extension/agents/vscode-node/organizationInstructionsContrib.ts new file mode 100644 index 0000000000..d3b9bd3e76 --- /dev/null +++ b/src/extension/agents/vscode-node/organizationInstructionsContrib.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { IExtensionContribution } from '../../common/contributions'; +import { OrganizationInstructionsProvider } from './organizationInstructionsProvider'; + +export class OrganizationInstructionsContribution extends Disposable implements IExtensionContribution { + readonly id = 'OrganizationInstructions'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + if ('registerInstructionsProvider' in vscode.chat) { + // Only register the provider if the setting is enabled + if (configurationService.getConfig(ConfigKey.UseOrganizationInstructions)) { + const provider = instantiationService.createInstance(OrganizationInstructionsProvider); + this._register(vscode.chat.registerInstructionsProvider(provider)); + } + } + } +} diff --git a/src/extension/agents/vscode-node/organizationInstructionsProvider.ts b/src/extension/agents/vscode-node/organizationInstructionsProvider.ts new file mode 100644 index 0000000000..520b1c9a7c --- /dev/null +++ b/src/extension/agents/vscode-node/organizationInstructionsProvider.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { FileType } from '../../../platform/filesystem/common/fileTypes'; +import { IGitService } from '../../../platform/git/common/gitService'; +import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { getRepoId } from '../../chatSessions/vscode/copilotCodingAgentUtils'; + +const InstructionFileExtension = '.instruction.md'; + +export class OrganizationInstructionsProvider extends Disposable implements vscode.InstructionsProvider { + + private readonly _onDidChangeInstructions = this._register(new vscode.EventEmitter()); + readonly onDidChangeInstructions = this._onDidChangeInstructions.event; + + private isFetching = false; + + constructor( + @IOctoKitService private readonly octoKitService: IOctoKitService, + @ILogService private readonly logService: ILogService, + @IGitService private readonly gitService: IGitService, + @IVSCodeExtensionContext readonly extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly fileSystem: IFileSystemService, + ) { + super(); + } + + private getCacheDir(): vscode.Uri | undefined { + if (!this.extensionContext.storageUri) { + return; + } + return vscode.Uri.joinPath(this.extensionContext.storageUri, 'githubInstructionsCache'); + } + + private getCacheFilename(orgLogin: string): string { + return orgLogin + InstructionFileExtension; + } + + async provideInstructions( + options: vscode.InstructionQueryOptions, + _token: vscode.CancellationToken + ): Promise { + try { + // Get repository information from the active git repository + const repoId = await getRepoId(this.gitService); + if (!repoId) { + this.logService.trace('[OrganizationInstructionsProvider] No active repository found'); + return []; + } + + const orgLogin = repoId.org; + + // Read from cache first + const cachedInstructions = await this.readFromCache(orgLogin); + + // Trigger async fetch to update cache + this.fetchAndUpdateCache(orgLogin, options).catch(error => { + this.logService.error(`[OrganizationInstructionsProvider] Error in background fetch: ${error}`); + }); + + return cachedInstructions; + } catch (error) { + this.logService.error(`[OrganizationInstructionsProvider] Error in provideInstructions: ${error}`); + return []; + } + } + + private async readFromCache( + orgLogin: string, + ): Promise { + try { + const cacheDir = this.getCacheDir(); + if (!cacheDir) { + this.logService.trace('[OrganizationInstructionsProvider] No workspace open, cannot use cache'); + return []; + } + + const cacheContents = await this.readCacheContents(orgLogin, cacheDir); + if (cacheContents === undefined) { + this.logService.trace(`[OrganizationInstructionsProvider] No cache found for org ${orgLogin}`); + return []; + } + + const instructions: vscode.CustomAgentResource[] = []; + const fileName = this.getCacheFilename(orgLogin); + const fileUri = vscode.Uri.joinPath(cacheDir, fileName); + instructions.push({ + name: orgLogin, + description: '', + uri: fileUri, + }); + + this.logService.trace(`[OrganizationInstructionsProvider] Loaded ${instructions.length} instructions from cache for org ${orgLogin}`); + return instructions; + } catch (error) { + this.logService.error(`[OrganizationInstructionsProvider] Error reading from cache: ${error}`); + return []; + } + } + + private async fetchAndUpdateCache( + orgLogin: string, + options: vscode.InstructionQueryOptions + ): Promise { + // Prevent concurrent fetches + if (this.isFetching) { + this.logService.trace('[OrganizationInstructionsProvider] Fetch already in progress, skipping'); + return; + } + + this.isFetching = true; + try { + this.logService.trace(`[OrganizationInstructionsProvider] Fetching custom instructions for org ${orgLogin}`); + + const instructions = await this.octoKitService.getOrgCustomInstructions(orgLogin); + const cacheDir = this.getCacheDir(); + if (!cacheDir) { + this.logService.trace('[OrganizationInstructionsProvider] No workspace open, cannot use cache'); + return; + } + + if (!instructions) { + this.logService.trace(`[OrganizationInstructionsProvider] No custom instructions found for org ${orgLogin}`); + return; + } + + // Ensure cache directory exists + try { + await this.fileSystem.stat(cacheDir); + } catch (error) { + // Directory doesn't exist, create it + await this.fileSystem.createDirectory(cacheDir); + } + + const existingInstructions = await this.readCacheContents(orgLogin, cacheDir); + const hasChanges = instructions !== existingInstructions; + + if (!hasChanges) { + this.logService.trace(`[OrganizationInstructionsProvider] No changes detected in cache for org ${orgLogin}`); + return; + } + + const fileName = this.getCacheFilename(orgLogin); + const fileUri = vscode.Uri.joinPath(cacheDir, fileName); + await this.fileSystem.writeFile(fileUri, new TextEncoder().encode(instructions)); + + this.logService.trace(`[OrganizationInstructionsProvider] Updated cache with instructions for org ${orgLogin}`); + + // Fire event to notify consumers that instructions have changed + this._onDidChangeInstructions.fire(); + } finally { + this.isFetching = false; + } + } + + private async readCacheContents(orgLogin: string, cacheDir: vscode.Uri): Promise { + try { + const files = await this.fileSystem.readDirectory(cacheDir); + for (const [filename, fileType] of files) { + if (fileType === FileType.File && filename === this.getCacheFilename(orgLogin)) { + const fileUri = vscode.Uri.joinPath(cacheDir, filename); + const content = await this.fileSystem.readFile(fileUri); + const text = new TextDecoder().decode(content); + return text; + } + } + } catch { + // Directory might not exist yet or other errors + } + return undefined; + } +} diff --git a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts index d5c7c2439a..5ba667fc71 100644 --- a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts +++ b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts @@ -117,6 +117,7 @@ class MockOctoKitService implements IOctoKitService { getPullRequestFiles = async () => []; closePullRequest = async () => false; getFileContent = async () => ''; + getOrgCustomInstructions = async () => undefined; async getCustomAgents(owner: string, repo: string, options?: CustomAgentListOptions): Promise { return this.customAgents; diff --git a/src/extension/agents/vscode-node/test/organizationInstructionsProvider.spec.ts b/src/extension/agents/vscode-node/test/organizationInstructionsProvider.spec.ts new file mode 100644 index 0000000000..b5d3121044 --- /dev/null +++ b/src/extension/agents/vscode-node/test/organizationInstructionsProvider.spec.ts @@ -0,0 +1,674 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import { afterEach, beforeEach, suite, test } from 'vitest'; +import * as vscode from 'vscode'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; +import { FileType } from '../../../../platform/filesystem/common/fileTypes'; +import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; +import { GithubRepoId, IGitService, RepoContext } from '../../../../platform/git/common/gitService'; +import { IOctoKitService } from '../../../../platform/github/common/githubService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { Event } from '../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { constObservable, observableValue } from '../../../../util/vs/base/common/observable'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { OrganizationInstructionsProvider } from '../organizationInstructionsProvider'; + +/** + * Mock implementation of IGitService for testing + */ +class MockGitService implements IGitService { + _serviceBrand: undefined; + isInitialized = true; + activeRepository = observableValue(this, undefined); + onDidOpenRepository = Event.None; + onDidCloseRepository = Event.None; + onDidFinishInitialization = Event.None; + + get repositories(): RepoContext[] { + const repo = this.activeRepository.get(); + return repo ? [repo] : []; + } + + setActiveRepository(repoId: GithubRepoId | undefined) { + if (repoId) { + this.activeRepository.set({ + rootUri: URI.file('/test/repo'), + headBranchName: undefined, + headCommitHash: undefined, + upstreamBranchName: undefined, + upstreamRemote: undefined, + isRebasing: false, + remoteFetchUrls: [`https://github.com/${repoId.org}/${repoId.repo}.git`], + remotes: [], + changes: undefined, + headBranchNameObs: constObservable(undefined), + headCommitHashObs: constObservable(undefined), + upstreamBranchNameObs: constObservable(undefined), + upstreamRemoteObs: constObservable(undefined), + isRebasingObs: constObservable(false), + isIgnored: async () => false, + }, undefined); + } else { + this.activeRepository.set(undefined, undefined); + } + } + + async getRepository(uri: URI): Promise { + return undefined; + } + + async getRepositoryFetchUrls(uri: URI): Promise | undefined> { + return undefined; + } + + async initialize(): Promise { } + async add(uri: URI, paths: string[]): Promise { } + async log(uri: URI, options?: any): Promise { + return []; + } + async diffBetween(uri: URI, ref1: string, ref2: string): Promise { + return []; + } + async diffWith(uri: URI, ref: string): Promise { + return []; + } + async diffIndexWithHEADShortStats(uri: URI): Promise { + return undefined; + } + async fetch(uri: URI, remote?: string, ref?: string, depth?: number): Promise { } + async getMergeBase(uri: URI, ref1: string, ref2: string): Promise { + return undefined; + } + async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string }): Promise { + return undefined; + } + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { } + async migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { } + + dispose() { } +} + +/** + * Mock implementation of IOctoKitService for testing + */ +class MockOctoKitService implements IOctoKitService { + _serviceBrand: undefined; + + private orgInstructions: Map = new Map(); + + getCurrentAuthedUser = async () => ({ login: 'testuser', name: 'Test User', avatar_url: '' }); + getCopilotPullRequestsForUser = async () => []; + getCopilotSessionsForPR = async () => []; + getSessionLogs = async () => ''; + getSessionInfo = async () => undefined; + postCopilotAgentJob = async () => undefined; + getJobByJobId = async () => undefined; + getJobBySessionId = async () => undefined; + addPullRequestComment = async () => null; + getAllOpenSessions = async () => []; + getPullRequestFromGlobalId = async () => null; + getPullRequestFiles = async () => []; + closePullRequest = async () => false; + getFileContent = async () => ''; + getCustomAgents = async () => []; + getCustomAgentDetails = async () => undefined; + + async getOrgCustomInstructions(orgLogin: string): Promise { + return this.orgInstructions.get(orgLogin); + } + + setOrgInstructions(orgLogin: string, instructions: string | undefined) { + if (instructions === undefined) { + this.orgInstructions.delete(orgLogin); + } else { + this.orgInstructions.set(orgLogin, instructions); + } + } + + clearInstructions() { + this.orgInstructions.clear(); + } +} + +/** + * Mock implementation of extension context for testing + */ +class MockExtensionContext { + storageUri: vscode.Uri | undefined; + + constructor(storageUri?: vscode.Uri) { + this.storageUri = storageUri; + } +} + +suite('OrganizationInstructionsProvider', () => { + let disposables: DisposableStore; + let mockGitService: MockGitService; + let mockOctoKitService: MockOctoKitService; + let mockFileSystem: MockFileSystemService; + let mockExtensionContext: MockExtensionContext; + let accessor: any; + let provider: OrganizationInstructionsProvider; + + beforeEach(() => { + disposables = new DisposableStore(); + + // Create mocks first + mockGitService = new MockGitService(); + mockOctoKitService = new MockOctoKitService(); + const storageUri = URI.file('/test/storage'); + mockExtensionContext = new MockExtensionContext(storageUri); + + // Set up testing services + const testingServiceCollection = createExtensionUnitTestingServices(disposables); + accessor = disposables.add(testingServiceCollection.createTestingAccessor()); + + mockFileSystem = accessor.get(IFileSystemService) as MockFileSystemService; + }); + + afterEach(() => { + disposables.dispose(); + mockOctoKitService.clearInstructions(); + }); + + function createProvider() { + // Create provider manually with all dependencies + provider = new OrganizationInstructionsProvider( + mockOctoKitService, + accessor.get(ILogService), + mockGitService, + mockExtensionContext as any, + mockFileSystem + ); + disposables.add(provider); + return provider; + } + + test('returns empty array when no active repository', async () => { + mockGitService.setActiveRepository(undefined); + const provider = createProvider(); + + const instructions = await provider.provideInstructions({}, {} as any); + + assert.deepEqual(instructions, []); + }); + + test('returns empty array when no storage URI available', async () => { + mockExtensionContext.storageUri = undefined; + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const instructions = await provider.provideInstructions({}, {} as any); + + assert.deepEqual(instructions, []); + }); + + test('returns cached instructions on first call', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Pre-populate cache + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + mockFileSystem.mockDirectory(cacheDir, [['testorg.instruction.md', FileType.File]]); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const instructionContent = `# Organization Instructions + +Always follow our coding standards.`; + mockFileSystem.mockFile(instructionFile, instructionContent); + + const instructions = await provider.provideInstructions({}, {} as any); + + assert.equal(instructions.length, 1); + assert.equal(instructions[0].name, 'testorg'); + assert.equal(instructions[0].description, ''); + }); + + test('fetches and caches instructions from API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Mock API response + const mockInstructions = `# Organization Instructions + +Always use TypeScript strict mode.`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + // First call returns cached (empty) results + const instructions1 = await provider.provideInstructions({}, {} as any); + assert.deepEqual(instructions1, []); + + // Wait for background fetch to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Second call should return newly cached instructions + const instructions2 = await provider.provideInstructions({}, {} as any); + assert.equal(instructions2.length, 1); + assert.equal(instructions2[0].name, 'testorg'); + }); + + test('caches instructions with correct content', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Coding Standards + +1. Use tabs for indentation +2. Follow TypeScript conventions +3. Write comprehensive tests`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check cached file content + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const contentBytes = await mockFileSystem.readFile(instructionFile); + const content = new TextDecoder().decode(contentBytes); + + assert.equal(content, mockInstructions); + }); + + test('fires change event when cache is updated', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Initial Instructions`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let eventFired = false; + provider.onDidChangeInstructions(() => { + eventFired = true; + }); + + // Update the instructions + const updatedInstructions = `# Updated Instructions`; + mockOctoKitService.setOrgInstructions('testorg', updatedInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(eventFired, true); + }); + + test('handles API errors gracefully', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Make the API throw an error + mockOctoKitService.getOrgCustomInstructions = async () => { + throw new Error('API Error'); + }; + + // Should not throw, should return empty array + const instructions = await provider.provideInstructions({}, {} as any); + assert.deepEqual(instructions, []); + }); + + test('prevents concurrent fetches when called multiple times rapidly', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + let apiCallCount = 0; + mockOctoKitService.getOrgCustomInstructions = async () => { + apiCallCount++; + // Simulate slow API call + await new Promise(resolve => setTimeout(resolve, 50)); + return 'Test instructions'; + }; + + // Make multiple concurrent calls + const promise1 = provider.provideInstructions({}, {} as any); + const promise2 = provider.provideInstructions({}, {} as any); + const promise3 = provider.provideInstructions({}, {} as any); + + await Promise.all([promise1, promise2, promise3]); + await new Promise(resolve => setTimeout(resolve, 100)); + + // API should only be called once due to isFetching guard + assert.equal(apiCallCount, 1); + }); + + test('does not fire change event when content is identical', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Stable Instructions`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventCount = 0; + provider.onDidChangeInstructions(() => { + changeEventCount++; + }); + + // Fetch again with identical content + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + // No change event should fire + assert.equal(changeEventCount, 0); + }); + + test('handles no instructions found from API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // API returns undefined (no instructions) + mockOctoKitService.setOrgInstructions('testorg', undefined); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should not create any cache files + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + try { + const files = await mockFileSystem.readDirectory(cacheDir); + assert.equal(files.length, 0); + } catch { + // Directory might not exist, which is also fine + } + }); + + test('generates correct cache filename for organization', async () => { + mockGitService.setActiveRepository(new GithubRepoId('mycompany', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Company Instructions`; + mockOctoKitService.setOrgInstructions('mycompany', mockInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check that file was created with correct name + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const instructionFile = URI.joinPath(cacheDir, 'mycompany.instruction.md'); + try { + const contentBytes = await mockFileSystem.readFile(instructionFile); + const content = new TextDecoder().decode(contentBytes); + assert.equal(content, mockInstructions); + } catch (error) { + assert.fail('Cache file should exist with correct name'); + } + }); + + test('handles repository context changes between calls', async () => { + const provider = createProvider(); + + // First call with org A + mockGitService.setActiveRepository(new GithubRepoId('orgA', 'repoA')); + + let capturedOrgLogin: string | undefined; + mockOctoKitService.getOrgCustomInstructions = async (orgLogin: string) => { + capturedOrgLogin = orgLogin; + return 'Org A instructions'; + }; + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + assert.equal(capturedOrgLogin, 'orgA'); + + // Change to org B + mockGitService.setActiveRepository(new GithubRepoId('orgB', 'repoB')); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should fetch from new organization + assert.equal(capturedOrgLogin, 'orgB'); + }); + + test('creates cache directory if it does not exist', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Test Instructions`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + // Initially no cache directory + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Cache directory should now exist + try { + const stat = await mockFileSystem.stat(cacheDir); + assert.ok(stat); + } catch { + assert.fail('Cache directory should have been created'); + } + }); + + test('reads existing cache even when directory check fails', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Pre-populate cache + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + mockFileSystem.mockDirectory(cacheDir, [['testorg.instruction.md', FileType.File]]); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const instructionContent = `# Existing Instructions`; + mockFileSystem.mockFile(instructionFile, instructionContent); + + const instructions = await provider.provideInstructions({}, {} as any); + + // Should successfully read cached instructions + assert.equal(instructions.length, 1); + assert.equal(instructions[0].name, 'testorg'); + }); + + test('handles cache read errors gracefully', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Make readDirectory throw an error + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + mockFileSystem.mockDirectory(cacheDir, []); // Empty directory + const originalReadDir = mockFileSystem.readDirectory.bind(mockFileSystem); + mockFileSystem.readDirectory = async () => { + throw new Error('Read error'); + }; + + // Should not throw, should return empty array + const instructions = await provider.provideInstructions({}, {} as any); + assert.deepEqual(instructions, []); + + // Restore original method + mockFileSystem.readDirectory = originalReadDir; + }); + + test('detects instruction additions', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Initial setup with no instructions + mockOctoKitService.setOrgInstructions('testorg', undefined); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventFired = false; + provider.onDidChangeInstructions(() => { + changeEventFired = true; + }); + + // Add new instructions + const newInstructions = `# New Instructions + +Follow these rules.`; + mockOctoKitService.setOrgInstructions('testorg', newInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(changeEventFired, true); + const instructions = await provider.provideInstructions({}, {} as any); + assert.equal(instructions.length, 1); + }); + + test('detects instruction removals', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Initial setup with instructions + const initialInstructions = `# Initial Instructions`; + mockOctoKitService.setOrgInstructions('testorg', initialInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + provider.onDidChangeInstructions(() => { + // Event listener registered for potential future use + }); + + // Remove instructions + mockOctoKitService.setOrgInstructions('testorg', undefined); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + // Note: Currently the implementation doesn't delete cache files when instructions are removed, + // so the change event might not fire. This test documents current behavior. + // The cached instructions would still be returned on the next call. + }); + + test('handles empty instructions string', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // API returns empty string + mockOctoKitService.setOrgInstructions('testorg', ''); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Empty strings are treated as "no instructions" and not cached + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + try { + await mockFileSystem.readFile(instructionFile); + assert.fail('Cache file should not exist for empty instructions'); + } catch { + // Expected - empty instructions are not cached + } + }); + + test('handles instructions with special characters', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockInstructions = `# Instructions + +Use "double quotes" and 'single quotes'. +Include special chars: @#$%^&*() +Unicode: 你好 🚀`; + mockOctoKitService.setOrgInstructions('testorg', mockInstructions); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check that special characters are preserved + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const contentBytes = await mockFileSystem.readFile(instructionFile); + const content = new TextDecoder().decode(contentBytes); + + assert.equal(content, mockInstructions); + }); + + test('handles very large instruction content', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Generate large content (e.g., 100KB) + const largeContent = '# Large Instructions\n\n' + 'x'.repeat(100000); + mockOctoKitService.setOrgInstructions('testorg', largeContent); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check that large content is handled correctly + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const contentBytes = await mockFileSystem.readFile(instructionFile); + const content = new TextDecoder().decode(contentBytes); + + assert.equal(content.length, largeContent.length); + }); + + test('returns correct URI for cached instruction resource', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Pre-populate cache + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + mockFileSystem.mockDirectory(cacheDir, [['testorg.instruction.md', FileType.File]]); + const instructionFile = URI.joinPath(cacheDir, 'testorg.instruction.md'); + const instructionContent = `# Test`; + mockFileSystem.mockFile(instructionFile, instructionContent); + + const instructions = await provider.provideInstructions({}, {} as any); + + assert.equal(instructions.length, 1); + assert.ok(instructions[0].uri); + assert.equal(instructions[0].uri.toString(), instructionFile.toString()); + }); + + test('handles multiple organizations in same cache directory', async () => { + const provider = createProvider(); + + // First organization + mockGitService.setActiveRepository(new GithubRepoId('org1', 'repo1')); + mockOctoKitService.setOrgInstructions('org1', '# Org 1 Instructions'); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Second organization + mockGitService.setActiveRepository(new GithubRepoId('org2', 'repo2')); + mockOctoKitService.setOrgInstructions('org2', '# Org 2 Instructions'); + + await provider.provideInstructions({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Both instruction files should exist in cache + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + const files = await mockFileSystem.readDirectory(cacheDir); + const instructionFiles = files.filter(([name]) => name.endsWith('.instruction.md')); + + assert.equal(instructionFiles.length, 2); + }); + + test('reads correct organization instructions when multiple are cached', async () => { + const provider = createProvider(); + + // Pre-populate cache with multiple organizations + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubInstructionsCache'); + mockFileSystem.mockDirectory(cacheDir, [ + ['org1.instruction.md', FileType.File], + ['org2.instruction.md', FileType.File], + ]); + mockFileSystem.mockFile(URI.joinPath(cacheDir, 'org1.instruction.md'), '# Org 1 Instructions'); + mockFileSystem.mockFile(URI.joinPath(cacheDir, 'org2.instruction.md'), '# Org 2 Instructions'); + + // Request instructions for org1 + mockGitService.setActiveRepository(new GithubRepoId('org1', 'repo1')); + const instructions = await provider.provideInstructions({}, {} as any); + + assert.equal(instructions.length, 1); + assert.equal(instructions[0].name, 'org1'); + }); +}); diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index f68ac06d17..06ad738789 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { OrganizationAndEnterpriseAgentContribution } from '../../agents/vscode-node/organizationAndEnterpriseAgentContrib'; +import { OrganizationInstructionsContribution } from '../../agents/vscode-node/organizationInstructionsContrib'; import { AuthenticationContrib } from '../../authentication/vscode-node/authentication.contribution'; import { BYOKContrib } from '../../byok/vscode-node/byokContribution'; import { ChatQuotaContribution } from '../../chat/vscode-node/chatQuota.contribution'; @@ -119,5 +120,6 @@ export const vscodeNodeChatContributions: IExtensionContributionFactory[] = [ asContributionFactory(McpSetupCommands), asContributionFactory(LanguageModelProxyContrib), asContributionFactory(OrganizationAndEnterpriseAgentContribution), + asContributionFactory(OrganizationInstructionsContribution), newWorkspaceContribution, ]; diff --git a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts index 59dd414ad6..7967959e24 100644 --- a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts @@ -364,6 +364,33 @@ declare module 'vscode' { provideCustomAgents(options: CustomAgentQueryOptions, token: CancellationToken): ProviderResult; } + // #endregion + + // #region InstructionsProvider + + /** + * Options for querying instructions. + */ + export interface InstructionQueryOptions { } + + /** + * A provider that supplies instruction resources for repositories. + */ + export interface InstructionsProvider { + /** + * An optional event to signal that instructions have changed. + */ + readonly onDidChangeInstructions?: Event; + + /** + * Provide the list of instruction resources available for a given repository. + * @param options Optional query parameters. + * @param token A cancellation token. + * @returns An array of instruction resources or a promise that resolves to such. + */ + provideInstructions(options: InstructionQueryOptions, token: CancellationToken): ProviderResult; + } + export namespace chat { /** * Register a provider for custom agents. @@ -371,6 +398,13 @@ declare module 'vscode' { * @returns A disposable that unregisters the provider when disposed. */ export function registerCustomAgentsProvider(provider: CustomAgentsProvider): Disposable; + + /** + * Register a provider for instructions. + * @param provider The instructions provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerInstructionsProvider(provider: InstructionsProvider): Disposable; } // #endregion diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index ffa9b0dd58..b23e616dac 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -869,6 +869,9 @@ export namespace ConfigKey { /** Enable custom agents from GitHub Enterprise/Organizations */ export const ShowOrganizationAndEnterpriseAgents = defineSetting('chat.customAgents.showOrganizationAndEnterpriseAgents', ConfigType.Simple, true); + /** Enable custom instructions from GitHub Organizations */ + export const UseOrganizationInstructions = defineSetting('chat.customInstructions.useOrganizationInstructions', ConfigType.Simple, true); + export const CompletionsFetcher = defineSetting('chat.completionsFetcher', ConfigType.ExperimentBased, undefined); export const NextEditSuggestionsFetcher = defineSetting('chat.nesFetcher', ConfigType.ExperimentBased, undefined); diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index 511412b18a..bd68d02fb4 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -301,6 +301,13 @@ export interface IOctoKitService { * @returns The file content as a string */ getFileContent(owner: string, repo: string, ref: string, path: string): Promise; + + /** + * Gets the custom instructions prompt for an organization. + * @param orgLogin The organization login + * @returns The prompt string or undefined if not available + */ + getOrgCustomInstructions(orgLogin: string): Promise; } /** diff --git a/src/platform/github/common/octoKitServiceImpl.ts b/src/platform/github/common/octoKitServiceImpl.ts index 1aec40de20..8f3a00c67f 100644 --- a/src/platform/github/common/octoKitServiceImpl.ts +++ b/src/platform/github/common/octoKitServiceImpl.ts @@ -312,4 +312,30 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } return this.getFileContentWithToken(owner, repo, ref, path, authToken); } + + async getOrgCustomInstructions(orgLogin: string): Promise { + try { + const authToken = (await this._authService.getPermissiveGitHubSession({ createIfNone: true }))?.accessToken; + if (!authToken) { + throw new Error('No authentication token available'); + } + const response = await this._capiClientService.makeRequest({ + method: 'GET', + headers: { + Authorization: `Bearer ${authToken}`, + } + }, { + type: RequestType.OrgCustomInstructions, + orgLogin + }); + if (!response.ok) { + throw new Error(`Failed to fetch custom instructions for org ${orgLogin}: ${response.statusText}`); + } + const data = await response.json() as { prompt: string }; + return data.prompt; + } catch (e) { + this._logService.error(e); + return undefined; + } + } } \ No newline at end of file