Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/githubOrgChatResourcesService.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import { IAuthenticationService } from '../../../platform/authentication/common/authentication';7import { AGENT_FILE_EXTENSION, INSTRUCTION_FILE_EXTENSION, PromptsType } from '../../../platform/customInstructions/common/promptTypes';8import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';9import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';10import { FileType } from '../../../platform/filesystem/common/fileTypes';11import { getGithubRepoIdFromFetchUrl, IGitService } from '../../../platform/git/common/gitService';12import { IOctoKitService } from '../../../platform/github/common/githubService';13import { ILogService } from '../../../platform/log/common/logService';14import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';15import { Disposable, DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle';16import { createDecorator } from '../../../util/vs/platform/instantiation/common/instantiation';1718export interface IGitHubOrgChatResourcesService extends IDisposable {19/**20* Returns the organization that should be used for the current session.21*/22getPreferredOrganizationName(): Promise<string | undefined>;2324/**25* Creates a polling subscription with a custom interval.26* The callback will be invoked at the specified interval.27* @param intervalMs The polling interval in milliseconds28* @param callback The callback to invoke on each poll cycle29* @returns A disposable that stops the polling when disposed30*/31startPolling(intervalMs: number, callback: (orgName: string) => Promise<void>): IDisposable;3233/**34* Reads a specific cached resource.35* @returns The content of the resource, or undefined if not found36*/37readCacheFile(type: PromptsType, orgName: string, filename: string): Promise<string | undefined>;3839/**40* Writes a resource to the cache.41* @returns True if the content was changed, false if unchanged42*/43writeCacheFile(type: PromptsType, orgName: string, filename: string, content: string, options?: { checkForChanges?: boolean }): Promise<boolean>;4445/**46* Deletes all cached resources of specified type for an organization.47* Optionally provide set of filenames to exclude from deletion.48*/49clearCache(type: PromptsType, orgName: string, exclude?: Set<string>): Promise<void>;5051/**52* Lists all cached resources for a specific organization and type.53* @returns The list of cached resources.54*/55listCachedFiles(type: PromptsType, orgName: string): Promise<vscode.ChatResource[]>;56}5758export const IGitHubOrgChatResourcesService = createDecorator<IGitHubOrgChatResourcesService>('IGitHubPromptFileService');5960/**61* Maps PromptsType to the cache subdirectory name.62*/63function getCacheSubdirectory(type: PromptsType): string {64switch (type) {65case PromptsType.instructions:66return 'instructions';67case PromptsType.agent:68return 'agents';69default:70throw new Error(`Unsupported PromptsType: ${type}`);71}72}7374/**75* Returns true if the filename is valid for the given PromptsType.76*/77function isValidFile(type: PromptsType, fileName: string): boolean {78switch (type) {79case PromptsType.instructions:80return fileName.endsWith(INSTRUCTION_FILE_EXTENSION);81case PromptsType.agent:82return fileName.endsWith(AGENT_FILE_EXTENSION);83default:84throw new Error(`Unsupported PromptsType: ${type}`);85}86}8788export class GitHubOrgChatResourcesService extends Disposable implements IGitHubOrgChatResourcesService {89private static readonly CACHE_ROOT = 'github';9091// private readonly _pollingSubscriptions = this._register(new DisposableStore());92private _cachedPreferredOrgName: Promise<string | undefined> | undefined;9394constructor(95@IAuthenticationService private readonly authService: IAuthenticationService,96@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,97@IFileSystemService private readonly fileSystem: IFileSystemService,98@IGitService private readonly gitService: IGitService,99@ILogService private readonly logService: ILogService,100@IOctoKitService private readonly octoKitService: IOctoKitService,101@IWorkspaceService private readonly workspaceService: IWorkspaceService,102) {103super();104105// Invalidate cached org name when workspace folders change106this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {107this.logService.trace('[GitHubOrgChatResourcesService] Workspace folders changed, invalidating cached org name');108this._cachedPreferredOrgName = undefined;109}));110111// Invalidate cached org name when authentication changes (sign in/out)112this._register(this.authService.onDidAuthenticationChange(() => {113this.logService.trace('[GitHubOrgChatResourcesService] Authentication changed, invalidating cached org name');114this._cachedPreferredOrgName = undefined;115}));116}117118async getPreferredOrganizationName(): Promise<string | undefined> {119if (!this._cachedPreferredOrgName) {120this._cachedPreferredOrgName = this.computePreferredOrganizationName();121}122return this._cachedPreferredOrgName;123}124125private async computePreferredOrganizationName(): Promise<string | undefined> {126// Check if user is signed in first127const currentUser = await this.octoKitService.getCurrentAuthedUser();128if (!currentUser) {129this.logService.trace('[GitHubOrgChatResourcesService] User is not signed in');130return undefined;131}132133// Use the organization from the current workspace's git repository, if any134const workspaceOrg = await this.getWorkspaceRepositoryOrganization();135this.logService.trace(`[GitHubOrgChatResourcesService] Workspace organization: ${workspaceOrg ?? 'none'}`);136if (workspaceOrg) {137this.logService.trace(`[GitHubOrgChatResourcesService] Using workspace organization: ${workspaceOrg}`);138return workspaceOrg;139}140141// Check if user has Copilot access through an organization (Business/Enterprise subscription)142// and prefer that organization if available143const copilotOrganizations = this.authService.copilotToken?.organizationLoginList ?? [];144this.logService.trace(`[GitHubOrgChatResourcesService] Copilot organizations: ${JSON.stringify(copilotOrganizations)}`);145if (copilotOrganizations.length > 0) {146const copilotOrg = copilotOrganizations[0];147this.logService.trace(`[GitHubOrgChatResourcesService] Using Copilot sign-in organization: ${copilotOrg}`);148return copilotOrg;149}150151// Fall back to the first organization the user belongs to152// Get the organizations the user is a member of153let userOrganizations: string[];154try {155userOrganizations = await this.octoKitService.getUserOrganizations({}, 1);156this.logService.trace(`[GitHubOrgChatResourcesService] User organizations: ${JSON.stringify(userOrganizations)}`);157if (userOrganizations.length === 0) {158this.logService.trace('[GitHubOrgChatResourcesService] No organizations found for user');159return undefined;160}161} catch (error) {162this.logService.error(`[GitHubOrgChatResourcesService] Error getting user organizations: ${error}`);163return undefined;164}165this.logService.trace(`[GitHubOrgChatResourcesService] Falling back to first user organization: ${userOrganizations[0]}`);166return userOrganizations[0];167}168169/**170* Gets the organization from the current workspace's git repository, if any.171*/172private async getWorkspaceRepositoryOrganization(): Promise<string | undefined> {173const workspaceFolders = this.workspaceService.getWorkspaceFolders();174if (workspaceFolders.length === 0) {175return undefined;176}177178try {179// TODO: Support multi-root workspaces by checking all folders.180// This would need workspace-aware context for deciding when to use which org, which is currently not in scope.181const repoInfo = await this.gitService.getRepositoryFetchUrls(workspaceFolders[0]);182if (!repoInfo?.remoteFetchUrls?.length) {183return undefined;184}185186// Try each remote URL to find a GitHub repo187for (const fetchUrl of repoInfo.remoteFetchUrls) {188if (!fetchUrl) {189continue;190}191const repoId = getGithubRepoIdFromFetchUrl(fetchUrl);192if (repoId) {193this.logService.trace(`[GitHubOrgChatResourcesService] Found GitHub repo: ${repoId.org}/${repoId.repo}`);194return repoId.org;195}196}197} catch (error) {198this.logService.trace(`[GitHubOrgChatResourcesService] Error getting workspace repository: ${error}`);199}200201return undefined;202}203204startPolling(intervalMs: number, callback: (orgName: string) => Promise<void>): IDisposable {205const disposables = new DisposableStore();206207let isPolling = false;208const poll = async () => {209if (isPolling) {210return;211}212isPolling = true;213try {214const orgName = await this.getPreferredOrganizationName();215if (orgName) {216try {217await callback(orgName);218} catch (error) {219this.logService.error(`[GitHubOrgChatResourcesService] Error in polling callback: ${error}`);220}221}222} finally {223isPolling = false;224}225};226227// Initial poll228void poll();229230// TODO: re-enable polling231// Set up interval polling232// const intervalId = setInterval(() => poll(), intervalMs);233// disposables.add(toDisposable(() => clearInterval(intervalId)));234235// this._pollingSubscriptions.add(disposables);236237return disposables;238}239240private getCacheDir(orgName: string, type: PromptsType): vscode.Uri {241const sanitizedOrg = this.sanitizeFilename(orgName);242const subdirectory = getCacheSubdirectory(type);243return vscode.Uri.joinPath(244this.extensionContext.globalStorageUri,245GitHubOrgChatResourcesService.CACHE_ROOT,246sanitizedOrg,247subdirectory248);249}250251private getCacheFileUri(orgName: string, type: PromptsType, filename: string): vscode.Uri {252return vscode.Uri.joinPath(this.getCacheDir(orgName, type), filename);253}254255private sanitizeFilename(name: string): string {256return name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();257}258259private async ensureCacheDir(orgName: string, type: PromptsType): Promise<void> {260const cacheDir = this.getCacheDir(orgName, type);261try {262await this.fileSystem.stat(cacheDir);263} catch {264// createDirectory should create parent directories recursively265await this.fileSystem.createDirectory(cacheDir);266}267}268269async readCacheFile(type: PromptsType, orgName: string, filename: string): Promise<string | undefined> {270try {271const fileUri = this.getCacheFileUri(orgName, type, filename);272const content = await this.fileSystem.readFile(fileUri);273return new TextDecoder().decode(content);274} catch {275this.logService.error(`[GitHubOrgChatResourcesService] Cache file not found: ${filename}`);276return undefined;277}278}279280async writeCacheFile(type: PromptsType, orgName: string, filename: string, content: string, options?: { checkForChanges?: boolean }): Promise<boolean> {281await this.ensureCacheDir(orgName, type);282const fileUri = this.getCacheFileUri(orgName, type, filename);283const contentBytes = new TextEncoder().encode(content);284285// Check for changes if requested286let hasChanges = true;287if (options?.checkForChanges) {288try {289hasChanges = false;290291// First check file size to avoid reading file if size differs292const stat = await this.fileSystem.stat(fileUri);293if (stat.size !== contentBytes.length) {294hasChanges = true;295}296297// Sizes match, need to compare content298const existingContent = await this.fileSystem.readFile(fileUri);299const existingText = new TextDecoder().decode(existingContent);300if (existingText !== content) {301this.logService.trace(`[GitHubOrgChatResourcesService] Skipped writing cache file: ${fileUri.toString()}`);302hasChanges = true;303} else {304// Content is the same, no need to write305return false;306}307} catch {308// File doesn't exist, so we have changes309hasChanges = true;310}311}312313await this.fileSystem.writeFile(fileUri, contentBytes);314this.logService.trace(`[GitHubOrgChatResourcesService] Wrote cache file: ${fileUri.toString()}`);315return hasChanges;316}317318async clearCache(type: PromptsType, orgName: string, exclude?: Set<string>): Promise<void> {319const cacheDir = this.getCacheDir(orgName, type);320321try {322const files = await this.fileSystem.readDirectory(cacheDir);323for (const [filename, fileType] of files) {324if (fileType === FileType.File && isValidFile(type, filename) && !exclude?.has(filename)) {325await this.fileSystem.delete(vscode.Uri.joinPath(cacheDir, filename));326this.logService.trace(`[GitHubOrgChatResourcesService] Deleted cache file: ${filename}`);327}328}329} catch {330// Directory might not exist331}332}333334async listCachedFiles(type: PromptsType, orgName: string): Promise<vscode.ChatResource[]> {335const resources: vscode.ChatResource[] = [];336const cacheDir = this.getCacheDir(orgName, type);337338try {339const files = await this.fileSystem.readDirectory(cacheDir);340for (const [filename, fileType] of files) {341if (fileType === FileType.File && isValidFile(type, filename)) {342const fileUri = vscode.Uri.joinPath(cacheDir, filename);343resources.push({ uri: fileUri });344}345}346} catch {347// Directory might not exist yet348this.logService.trace(`[GitHubOrgChatResourcesService] Cache directory does not exist: ${cacheDir.toString()}`);349}350351return resources;352}353}354355356