Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5593,7 +5593,7 @@
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
"@sinclair/typebox": "^0.34.41",
"@vscode/copilot-api": "^0.2.9",
"@vscode/copilot-api": "^0.2.12",
"@vscode/extension-telemetry": "^1.2.0",
"@vscode/l10n": "^0.0.18",
"@vscode/prompt-tsx": "^0.4.0-alpha.6",
Expand Down
3 changes: 2 additions & 1 deletion src/extension/agents/vscode-node/test/mockOctoKitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, IOctoKitService, PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';
import { CCAEnabledResult, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, IOctoKitService, PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';

/**
* Mock implementation of IOctoKitService for testing
Expand Down Expand Up @@ -36,6 +36,7 @@ export class MockOctoKitService implements IOctoKitService {
getRecentlyCommittedRepositories = async () => [];
getCopilotAgentModels = async () => [];
getAssignableActors = async () => [];
isCCAEnabled = async (): Promise<CCAEnabledResult> => ({ enabled: true });

getUserOrganizations = async (_authOptions?: { createIfNone?: boolean }) => this.userOrganizations;
getOrganizationRepositories = async (org: string) => [org === 'testorg' ? 'testrepo' : 'repo'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/ext
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService';
import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI';
import { IGithubRepositoryService, IOctoKitService, JobInfo, RemoteAgentJobResponse } from '../../../platform/github/common/githubService';
import { CCAEnabledResult, IGithubRepositoryService, IOctoKitService, JobInfo, RemoteAgentJobResponse } from '../../../platform/github/common/githubService';
import { ILogService } from '../../../platform/log/common/logService';
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
Expand Down Expand Up @@ -177,6 +177,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C

private _partnerAgentsAvailableCache: Map<string, { id: string; name: string; at?: string }[]> | undefined;

// Cache for CCA enabled status per repository (key: "owner/repo")
private _ccaEnabledCache: Map<string, CCAEnabledResult> | undefined;

// Title
private TITLE = vscode.l10n.t('Delegate to cloud agent');

Expand Down Expand Up @@ -393,9 +396,59 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
this.activeSessionIds.clear();
this.stopActiveSessionPolling();
this._partnerAgentsAvailableCache = undefined;
this._ccaEnabledCache = undefined;
this._onDidChangeChatSessionItems.fire();
}

/**
* Checks if the Copilot cloud agent is enabled for a repository.
* Results are cached per repository until refresh() is called.
* @param owner Repository owner
* @param repo Repository name
* @returns CCAEnabledResult with enabled status and optional status code
*/
private async checkCCAEnabled(owner: string, repo: string): Promise<CCAEnabledResult> {
const cacheKey = `${owner}/${repo}`;

if (!this._ccaEnabledCache) {
this._ccaEnabledCache = new Map();
}

const cached = this._ccaEnabledCache.get(cacheKey);
if (cached !== undefined) {
this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: using cached CCA enabled status for ${owner}/${repo}: ${cached.enabled}`);
return cached;
}

const result = await this._octoKitService.isCCAEnabled(owner, repo, { createIfNone: false });
this._ccaEnabledCache.set(cacheKey, result);

this.telemetry.sendTelemetryEvent('copilot.codingAgent.CCAIsEnabledCheck', { microsoft: true, github: false }, {
enabled: String(result.enabled),
statusCode: String(result.statusCode ?? 'none'),
cacheHit: 'false',
});

this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: fetched CCA enabled status for ${owner}/${repo}: ${result.enabled}`);
return result;
}

/**
* Gets user-friendly error message for disabled CCA status.
* @param result The CCAEnabledResult to get message for
* @returns User-friendly error message
*/
private getCCADisabledMessage(result: CCAEnabledResult): string {
if (result.statusCode === 422) {
return vscode.l10n.t('Cloud agent is unable to create pull requests in this repository. Please verify repository rules allow this operation.');
}
if (result.statusCode === 401) {
return vscode.l10n.t('Cloud agent is not authorized to run on this repository. This may be because the Copilot coding agent is disabled for your organization, or your active GitHub account does not have push access to the target repository.');
}
// Default to 403 'disabled' message
return vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', 'https://github.com/settings/copilot/coding_agent');
}

private stopActiveSessionPolling(): void {
if (this.activeSessionPollingInterval) {
clearInterval(this.activeSessionPollingInterval);
Expand Down Expand Up @@ -576,6 +629,20 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
const repoIds = await getRepoId(this._gitService);
const repoId = repoIds?.[0];

const workspaceFolders = vscode.workspace.workspaceFolders;
const isSingleRepoWorkspace = workspaceFolders?.length === 1 && repoIds?.length === 1;
let ccaEnabledResult: { enabled?: boolean; statusCode?: number } | undefined;
let isCcaEnabled = true;
if (isSingleRepoWorkspace && repoId) {
ccaEnabledResult = await this.checkCCAEnabled(repoId.org, repoId.repo);
isCcaEnabled = ccaEnabledResult.enabled !== false;
}
if (!isCcaEnabled && repoId) {
this.logService.trace(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: CCA disabled for ${repoId.org}/${repoId.repo}, statusCode: ${ccaEnabledResult?.statusCode}`);
// Return empty options to disable the feature in the UI
return { optionGroups: [] };
}

try {
// Fetch agents (requires repo), models (global), and partner agents in parallel
const [customAgents, models, partnerAgents] = await Promise.allSettled([
Expand Down Expand Up @@ -2156,6 +2223,12 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
repoName = repoId.repo;
}

// Check if CCA is enabled before posting job
const ccaEnabled = await this.checkCCAEnabled(repoOwner, repoName);
if (ccaEnabled.enabled === false) {
throw new Error(this.getCCADisabledMessage(ccaEnabled));
}

if (isTruncated) {
stream.progress(vscode.l10n.t('Truncating context'));
const truncationResult = await vscode.window.showWarningMessage(
Expand Down
28 changes: 28 additions & 0 deletions src/platform/github/common/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export interface IOctoKitUser {
avatar_url: string;
}

/**
* Result of checking if Copilot cloud agent is enabled for a repository.
*/
export interface CCAEnabledResult {
/**
* Whether the cloud agent is enabled. `undefined` if unable to determine.
*/
enabled: boolean | undefined;
/**
* The HTTP status code when the cloud agent is disabled (401, 403, or 422).
*/
statusCode?: 401 | 403 | 422;
}

export interface IOctoKitSessionInfo {
name: string;
owner_id: number;
Expand Down Expand Up @@ -383,6 +397,20 @@ export interface IOctoKitService {
* @returns An array of assignable actors with their login names
*/
getAssignableActors(owner: string, repo: string, authOptions: AuthOptions): Promise<AssignableActor[]>;

/**
* Checks if the Copilot cloud agent is enabled for a repository.
* @param owner The repository owner
* @param repo The repository name
* @param authOptions - Authentication options. By default, uses silent auth.
* @returns An object indicating enabled status and status code if disabled.
* - 200: enabled = true
* - 401: enabled = false, statusCode = 401
* - 403: enabled = false, statusCode = 403
* - 422: enabled = false, statusCode = 422
* - Other errors: enabled = undefined
*/
isCCAEnabled(owner: string, repo: string, authOptions: AuthOptions): Promise<CCAEnabledResult>;
}

/**
Expand Down
41 changes: 40 additions & 1 deletion src/platform/github/common/octoKitServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ILogService } from '../../log/common/logService';
import { IFetcherService } from '../../networking/common/fetcherService';
import { ITelemetryService } from '../../telemetry/common/telemetry';
import { AssignableActor, getAssignableActorsWithAssignableUsers, getAssignableActorsWithSuggestedActors, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI';
import { BaseOctoKitService, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService';
import { BaseOctoKitService, CCAEnabledResult, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService';

export class OctoKitService extends BaseOctoKitService implements IOctoKitService {
declare readonly _serviceBrand: undefined;
Expand Down Expand Up @@ -460,4 +460,43 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic
return [];
}
}

async isCCAEnabled(owner: string, repo: string, authOptions: { createIfNone?: boolean }): Promise<CCAEnabledResult> {
try {
const authToken = (await this._authService.getGitHubSession('permissive', authOptions.createIfNone ? { createIfNone: true } : { silent: true }))?.accessToken;
if (!authToken) {
this._logService.trace('No authentication token available for isCCAEnabled');
return { enabled: undefined };
}
const response = await this._capiClientService.makeRequest<Response>({
method: 'GET',
headers: {
Authorization: `Bearer ${authToken}`,
}
}, { type: RequestType.CopilotAgentJobEnabled, owner, repo });

if (response.ok) {
// 200 - OK - CCA is enabled and repository rules pass
return { enabled: true };
}

switch (response.status) {
case 401:
// 401 - Unauthorized - Unauthenticated request
return { enabled: false, statusCode: 401 };
case 403:
// 403 - Forbidden - CCA disabled
return { enabled: false, statusCode: 403 };
case 422:
// 422 - Unprocessable entity - Repository rules violation
return { enabled: false, statusCode: 422 };
default:
this._logService.trace(`Unexpected status code for isCCAEnabled: ${response.status}`);
return { enabled: undefined };
}
} catch (e) {
this._logService.error(`Error checking if CCA is enabled: ${e}`);
return { enabled: undefined };
}
}
}