Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/githubOrgChatResourcesService.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
8
import { AGENT_FILE_EXTENSION, INSTRUCTION_FILE_EXTENSION, PromptsType } from '../../../platform/customInstructions/common/promptTypes';
9
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
10
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
11
import { FileType } from '../../../platform/filesystem/common/fileTypes';
12
import { getGithubRepoIdFromFetchUrl, IGitService } from '../../../platform/git/common/gitService';
13
import { IOctoKitService } from '../../../platform/github/common/githubService';
14
import { ILogService } from '../../../platform/log/common/logService';
15
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
16
import { Disposable, DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle';
17
import { createDecorator } from '../../../util/vs/platform/instantiation/common/instantiation';
18
19
export interface IGitHubOrgChatResourcesService extends IDisposable {
20
/**
21
* Returns the organization that should be used for the current session.
22
*/
23
getPreferredOrganizationName(): Promise<string | undefined>;
24
25
/**
26
* Creates a polling subscription with a custom interval.
27
* The callback will be invoked at the specified interval.
28
* @param intervalMs The polling interval in milliseconds
29
* @param callback The callback to invoke on each poll cycle
30
* @returns A disposable that stops the polling when disposed
31
*/
32
startPolling(intervalMs: number, callback: (orgName: string) => Promise<void>): IDisposable;
33
34
/**
35
* Reads a specific cached resource.
36
* @returns The content of the resource, or undefined if not found
37
*/
38
readCacheFile(type: PromptsType, orgName: string, filename: string): Promise<string | undefined>;
39
40
/**
41
* Writes a resource to the cache.
42
* @returns True if the content was changed, false if unchanged
43
*/
44
writeCacheFile(type: PromptsType, orgName: string, filename: string, content: string, options?: { checkForChanges?: boolean }): Promise<boolean>;
45
46
/**
47
* Deletes all cached resources of specified type for an organization.
48
* Optionally provide set of filenames to exclude from deletion.
49
*/
50
clearCache(type: PromptsType, orgName: string, exclude?: Set<string>): Promise<void>;
51
52
/**
53
* Lists all cached resources for a specific organization and type.
54
* @returns The list of cached resources.
55
*/
56
listCachedFiles(type: PromptsType, orgName: string): Promise<vscode.ChatResource[]>;
57
}
58
59
export const IGitHubOrgChatResourcesService = createDecorator<IGitHubOrgChatResourcesService>('IGitHubPromptFileService');
60
61
/**
62
* Maps PromptsType to the cache subdirectory name.
63
*/
64
function getCacheSubdirectory(type: PromptsType): string {
65
switch (type) {
66
case PromptsType.instructions:
67
return 'instructions';
68
case PromptsType.agent:
69
return 'agents';
70
default:
71
throw new Error(`Unsupported PromptsType: ${type}`);
72
}
73
}
74
75
/**
76
* Returns true if the filename is valid for the given PromptsType.
77
*/
78
function isValidFile(type: PromptsType, fileName: string): boolean {
79
switch (type) {
80
case PromptsType.instructions:
81
return fileName.endsWith(INSTRUCTION_FILE_EXTENSION);
82
case PromptsType.agent:
83
return fileName.endsWith(AGENT_FILE_EXTENSION);
84
default:
85
throw new Error(`Unsupported PromptsType: ${type}`);
86
}
87
}
88
89
export class GitHubOrgChatResourcesService extends Disposable implements IGitHubOrgChatResourcesService {
90
private static readonly CACHE_ROOT = 'github';
91
92
// private readonly _pollingSubscriptions = this._register(new DisposableStore());
93
private _cachedPreferredOrgName: Promise<string | undefined> | undefined;
94
95
constructor(
96
@IAuthenticationService private readonly authService: IAuthenticationService,
97
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
98
@IFileSystemService private readonly fileSystem: IFileSystemService,
99
@IGitService private readonly gitService: IGitService,
100
@ILogService private readonly logService: ILogService,
101
@IOctoKitService private readonly octoKitService: IOctoKitService,
102
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
103
) {
104
super();
105
106
// Invalidate cached org name when workspace folders change
107
this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {
108
this.logService.trace('[GitHubOrgChatResourcesService] Workspace folders changed, invalidating cached org name');
109
this._cachedPreferredOrgName = undefined;
110
}));
111
112
// Invalidate cached org name when authentication changes (sign in/out)
113
this._register(this.authService.onDidAuthenticationChange(() => {
114
this.logService.trace('[GitHubOrgChatResourcesService] Authentication changed, invalidating cached org name');
115
this._cachedPreferredOrgName = undefined;
116
}));
117
}
118
119
async getPreferredOrganizationName(): Promise<string | undefined> {
120
if (!this._cachedPreferredOrgName) {
121
this._cachedPreferredOrgName = this.computePreferredOrganizationName();
122
}
123
return this._cachedPreferredOrgName;
124
}
125
126
private async computePreferredOrganizationName(): Promise<string | undefined> {
127
// Check if user is signed in first
128
const currentUser = await this.octoKitService.getCurrentAuthedUser();
129
if (!currentUser) {
130
this.logService.trace('[GitHubOrgChatResourcesService] User is not signed in');
131
return undefined;
132
}
133
134
// Use the organization from the current workspace's git repository, if any
135
const workspaceOrg = await this.getWorkspaceRepositoryOrganization();
136
this.logService.trace(`[GitHubOrgChatResourcesService] Workspace organization: ${workspaceOrg ?? 'none'}`);
137
if (workspaceOrg) {
138
this.logService.trace(`[GitHubOrgChatResourcesService] Using workspace organization: ${workspaceOrg}`);
139
return workspaceOrg;
140
}
141
142
// Check if user has Copilot access through an organization (Business/Enterprise subscription)
143
// and prefer that organization if available
144
const copilotOrganizations = this.authService.copilotToken?.organizationLoginList ?? [];
145
this.logService.trace(`[GitHubOrgChatResourcesService] Copilot organizations: ${JSON.stringify(copilotOrganizations)}`);
146
if (copilotOrganizations.length > 0) {
147
const copilotOrg = copilotOrganizations[0];
148
this.logService.trace(`[GitHubOrgChatResourcesService] Using Copilot sign-in organization: ${copilotOrg}`);
149
return copilotOrg;
150
}
151
152
// Fall back to the first organization the user belongs to
153
// Get the organizations the user is a member of
154
let userOrganizations: string[];
155
try {
156
userOrganizations = await this.octoKitService.getUserOrganizations({}, 1);
157
this.logService.trace(`[GitHubOrgChatResourcesService] User organizations: ${JSON.stringify(userOrganizations)}`);
158
if (userOrganizations.length === 0) {
159
this.logService.trace('[GitHubOrgChatResourcesService] No organizations found for user');
160
return undefined;
161
}
162
} catch (error) {
163
this.logService.error(`[GitHubOrgChatResourcesService] Error getting user organizations: ${error}`);
164
return undefined;
165
}
166
this.logService.trace(`[GitHubOrgChatResourcesService] Falling back to first user organization: ${userOrganizations[0]}`);
167
return userOrganizations[0];
168
}
169
170
/**
171
* Gets the organization from the current workspace's git repository, if any.
172
*/
173
private async getWorkspaceRepositoryOrganization(): Promise<string | undefined> {
174
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
175
if (workspaceFolders.length === 0) {
176
return undefined;
177
}
178
179
try {
180
// TODO: Support multi-root workspaces by checking all folders.
181
// This would need workspace-aware context for deciding when to use which org, which is currently not in scope.
182
const repoInfo = await this.gitService.getRepositoryFetchUrls(workspaceFolders[0]);
183
if (!repoInfo?.remoteFetchUrls?.length) {
184
return undefined;
185
}
186
187
// Try each remote URL to find a GitHub repo
188
for (const fetchUrl of repoInfo.remoteFetchUrls) {
189
if (!fetchUrl) {
190
continue;
191
}
192
const repoId = getGithubRepoIdFromFetchUrl(fetchUrl);
193
if (repoId) {
194
this.logService.trace(`[GitHubOrgChatResourcesService] Found GitHub repo: ${repoId.org}/${repoId.repo}`);
195
return repoId.org;
196
}
197
}
198
} catch (error) {
199
this.logService.trace(`[GitHubOrgChatResourcesService] Error getting workspace repository: ${error}`);
200
}
201
202
return undefined;
203
}
204
205
startPolling(intervalMs: number, callback: (orgName: string) => Promise<void>): IDisposable {
206
const disposables = new DisposableStore();
207
208
let isPolling = false;
209
const poll = async () => {
210
if (isPolling) {
211
return;
212
}
213
isPolling = true;
214
try {
215
const orgName = await this.getPreferredOrganizationName();
216
if (orgName) {
217
try {
218
await callback(orgName);
219
} catch (error) {
220
this.logService.error(`[GitHubOrgChatResourcesService] Error in polling callback: ${error}`);
221
}
222
}
223
} finally {
224
isPolling = false;
225
}
226
};
227
228
// Initial poll
229
void poll();
230
231
// TODO: re-enable polling
232
// Set up interval polling
233
// const intervalId = setInterval(() => poll(), intervalMs);
234
// disposables.add(toDisposable(() => clearInterval(intervalId)));
235
236
// this._pollingSubscriptions.add(disposables);
237
238
return disposables;
239
}
240
241
private getCacheDir(orgName: string, type: PromptsType): vscode.Uri {
242
const sanitizedOrg = this.sanitizeFilename(orgName);
243
const subdirectory = getCacheSubdirectory(type);
244
return vscode.Uri.joinPath(
245
this.extensionContext.globalStorageUri,
246
GitHubOrgChatResourcesService.CACHE_ROOT,
247
sanitizedOrg,
248
subdirectory
249
);
250
}
251
252
private getCacheFileUri(orgName: string, type: PromptsType, filename: string): vscode.Uri {
253
return vscode.Uri.joinPath(this.getCacheDir(orgName, type), filename);
254
}
255
256
private sanitizeFilename(name: string): string {
257
return name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
258
}
259
260
private async ensureCacheDir(orgName: string, type: PromptsType): Promise<void> {
261
const cacheDir = this.getCacheDir(orgName, type);
262
try {
263
await this.fileSystem.stat(cacheDir);
264
} catch {
265
// createDirectory should create parent directories recursively
266
await this.fileSystem.createDirectory(cacheDir);
267
}
268
}
269
270
async readCacheFile(type: PromptsType, orgName: string, filename: string): Promise<string | undefined> {
271
try {
272
const fileUri = this.getCacheFileUri(orgName, type, filename);
273
const content = await this.fileSystem.readFile(fileUri);
274
return new TextDecoder().decode(content);
275
} catch {
276
this.logService.error(`[GitHubOrgChatResourcesService] Cache file not found: ${filename}`);
277
return undefined;
278
}
279
}
280
281
async writeCacheFile(type: PromptsType, orgName: string, filename: string, content: string, options?: { checkForChanges?: boolean }): Promise<boolean> {
282
await this.ensureCacheDir(orgName, type);
283
const fileUri = this.getCacheFileUri(orgName, type, filename);
284
const contentBytes = new TextEncoder().encode(content);
285
286
// Check for changes if requested
287
let hasChanges = true;
288
if (options?.checkForChanges) {
289
try {
290
hasChanges = false;
291
292
// First check file size to avoid reading file if size differs
293
const stat = await this.fileSystem.stat(fileUri);
294
if (stat.size !== contentBytes.length) {
295
hasChanges = true;
296
}
297
298
// Sizes match, need to compare content
299
const existingContent = await this.fileSystem.readFile(fileUri);
300
const existingText = new TextDecoder().decode(existingContent);
301
if (existingText !== content) {
302
this.logService.trace(`[GitHubOrgChatResourcesService] Skipped writing cache file: ${fileUri.toString()}`);
303
hasChanges = true;
304
} else {
305
// Content is the same, no need to write
306
return false;
307
}
308
} catch {
309
// File doesn't exist, so we have changes
310
hasChanges = true;
311
}
312
}
313
314
await this.fileSystem.writeFile(fileUri, contentBytes);
315
this.logService.trace(`[GitHubOrgChatResourcesService] Wrote cache file: ${fileUri.toString()}`);
316
return hasChanges;
317
}
318
319
async clearCache(type: PromptsType, orgName: string, exclude?: Set<string>): Promise<void> {
320
const cacheDir = this.getCacheDir(orgName, type);
321
322
try {
323
const files = await this.fileSystem.readDirectory(cacheDir);
324
for (const [filename, fileType] of files) {
325
if (fileType === FileType.File && isValidFile(type, filename) && !exclude?.has(filename)) {
326
await this.fileSystem.delete(vscode.Uri.joinPath(cacheDir, filename));
327
this.logService.trace(`[GitHubOrgChatResourcesService] Deleted cache file: ${filename}`);
328
}
329
}
330
} catch {
331
// Directory might not exist
332
}
333
}
334
335
async listCachedFiles(type: PromptsType, orgName: string): Promise<vscode.ChatResource[]> {
336
const resources: vscode.ChatResource[] = [];
337
const cacheDir = this.getCacheDir(orgName, type);
338
339
try {
340
const files = await this.fileSystem.readDirectory(cacheDir);
341
for (const [filename, fileType] of files) {
342
if (fileType === FileType.File && isValidFile(type, filename)) {
343
const fileUri = vscode.Uri.joinPath(cacheDir, filename);
344
resources.push({ uri: fileUri });
345
}
346
}
347
} catch {
348
// Directory might not exist yet
349
this.logService.trace(`[GitHubOrgChatResourcesService] Cache directory does not exist: ${cacheDir.toString()}`);
350
}
351
352
return resources;
353
}
354
}
355
356