Skip to content

Commit c8c1422

Browse files
pwang347Copilot
andauthored
Add support for organization custom instructions (#2310)
* PR * activation * fix test * wip * update * tests * Update src/platform/github/common/githubService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/extension/agents/vscode-node/organizationInstructionsProvider.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/platform/github/common/octoKitServiceImpl.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update * update setting name * wip * fix * use enum * PR * nit * add polling * tests * cleanup * use helper * remove instructions * clean * Revert "clean" This reverts commit d00db38. * Revert "remove instructions" This reverts commit 24f3e2e. * update * update per discussion * clean * update * update * Update * update * update * clean * test * test * fixes * fixes * fix tests * update test * yaml * fix * config update * clean * fix --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c4d77e0 commit c8c1422

18 files changed

+2451
-716
lines changed

package.json

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@
8484
"onUri",
8585
"onFileSystem:ccreq",
8686
"onFileSystem:ccsettings",
87-
"onCustomAgentProvider"
87+
"onCustomAgentProvider",
88+
"onInstructionsProvider"
8889
],
8990
"main": "./dist/extension",
9091
"l10n": "./l10n",
@@ -2761,6 +2762,16 @@
27612762
"default": true,
27622763
"description": "%github.copilot.config.customInstructionsInSystemMessage%"
27632764
},
2765+
"github.copilot.chat.organizationCustomAgents.enabled": {
2766+
"type": "boolean",
2767+
"default": true,
2768+
"description": "%github.copilot.config.organizationCustomAgents.enabled%"
2769+
},
2770+
"github.copilot.chat.organizationInstructions.enabled": {
2771+
"type": "boolean",
2772+
"default": true,
2773+
"description": "%github.copilot.config.organizationInstructions.enabled%"
2774+
},
27642775
"github.copilot.chat.agent.currentEditorContext.enabled": {
27652776
"type": "boolean",
27662777
"default": true,
@@ -3558,14 +3569,6 @@
35583569
"electron-fetch",
35593570
"node-fetch"
35603571
]
3561-
},
3562-
"github.copilot.chat.customAgents.showOrganizationAndEnterpriseAgents": {
3563-
"type": "boolean",
3564-
"default": false,
3565-
"description": "%github.copilot.config.customAgents.showOrganizationAndEnterpriseAgents%",
3566-
"tags": [
3567-
"experimental"
3568-
]
35693572
}
35703573
}
35713574
},

package.nls.json

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

0 commit comments

Comments
 (0)