diff --git a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts index 531ca23be9..f782e64d9e 100644 --- a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts +++ b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts @@ -44,6 +44,7 @@ class MockOctoKitService implements IOctoKitService { getUserOrganizations = async () => this.userOrganizations; getOrganizationRepositories = async (org: string) => [org === 'testorg' ? 'testrepo' : 'repo']; getCopilotAgentModels = async () => []; + getAssignableActors = async () => []; async getCustomAgents(owner: string, repo: string, options: CustomAgentListOptions, authOptions: { createIfNone?: boolean }): Promise { if (!(await this.getCurrentAuthedUser())) { diff --git a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 3401df0fa1..93cac11c07 100644 --- a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -62,10 +62,10 @@ const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore'; // TODO: No API from GH yet. -const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string }[] = [ - { id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot' }, - { id: '2246796', name: 'Claude', at: 'claude[agent]' }, - { id: '2248422', name: 'Codex', at: 'codex[agent]' } +const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string; assignableActorLogin?: string }[] = [ + { id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot', assignableActorLogin: 'copilot-swe-agent' }, + { id: '2246796', name: 'Claude', at: 'claude[agent]', assignableActorLogin: 'anthropic-code-agent' }, + { id: '2248422', name: 'Codex', at: 'codex[agent]', assignableActorLogin: 'openai-code-agent' } ]; /** @@ -162,6 +162,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C private readonly plainTextRenderer = new PlainTextRenderer(); private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService); + private _partnerAgentsAvailableCache: Map | undefined; + // Title private TITLE = vscode.l10n.t('Delegate to cloud agent'); @@ -277,6 +279,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C this.cachedSessionItems = undefined; this.activeSessionIds.clear(); this.stopActiveSessionPolling(); + this._partnerAgentsAvailableCache = undefined; this._onDidChangeChatSessionItems.fire(); } @@ -356,6 +359,44 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } } + /** + * Queries for available partner agents by checking if known CCA logins are assignable in the repository. + * TODO: Remove once given a proper API + */ + private async getAvailablePartnerAgents(owner: string, repo: string): Promise<{ id: string; name: string; at?: string }[]> { + const cacheKey = `${owner}/${repo}`; + + // Return cached result if available + if (this._partnerAgentsAvailableCache?.has(cacheKey)) { + return this._partnerAgentsAvailableCache.get(cacheKey)!; + } + + try { + // Fetch assignable actors for the repository + const assignableActors = await this._octoKitService.getAssignableActors(owner, repo, { createIfNone: false }); + + // Check which agents from HARDCODED_PARTNER_AGENTS are assignable + const availableAgents: { id: string; name: string; at?: string }[] = []; + + for (const agent of HARDCODED_PARTNER_AGENTS) { + const isAssignable = agent.assignableActorLogin && assignableActors.some(actor => actor.login === agent.assignableActorLogin); + if (isAssignable) { + availableAgents.push(agent); + } + } + + if (!this._partnerAgentsAvailableCache) { + this._partnerAgentsAvailableCache = new Map(); + } + this._partnerAgentsAvailableCache.set(cacheKey, availableAgents); + + return availableAgents; + } catch (error) { + this.logService.error(`Error fetching partner agents: ${error}`); + return []; + } + } + async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise { this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions Start'); @@ -363,17 +404,18 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C const repoId = await getRepoId(this._gitService); try { - // Fetch agents (requires repo) and models (global) in parallel - const [customAgents, models] = await Promise.all([ + // Fetch agents (requires repo), models (global), and partner agents in parallel + const [customAgents, models, partnerAgents] = await Promise.all([ repoId ? this._octoKitService.getCustomAgents(repoId.org, repoId.repo, { excludeInvalidConfig: true }, { createIfNone: false }) : Promise.resolve([]), - this._octoKitService.getCopilotAgentModels({ createIfNone: false }) + this._octoKitService.getCopilotAgentModels({ createIfNone: false }), + repoId ? this.getAvailablePartnerAgents(repoId.org, repoId.repo) : Promise.resolve([]) ]); // Partner agents const partnerAgentsEnabled = this._configurationService.getConfig(ConfigKey.Advanced.CCAPartnerAgents); - if (partnerAgentsEnabled) { - const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = HARDCODED_PARTNER_AGENTS.map(agent => ({ + if (partnerAgentsEnabled && partnerAgents.length > 0) { + const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = partnerAgents.map(agent => ({ id: agent.id, name: agent.name, })); diff --git a/src/platform/github/common/githubAPI.ts b/src/platform/github/common/githubAPI.ts index 1ed8c75a2a..638a02ecdc 100644 --- a/src/platform/github/common/githubAPI.ts +++ b/src/platform/github/common/githubAPI.ts @@ -78,6 +78,32 @@ export interface PullRequestComment { url: string; } +export interface AssignableActor { + __typename: string; + login: string; + avatarUrl?: string; + url?: string; +} + +export interface AssignableActorsResponse { + repository: { + suggestedActors?: { + nodes: AssignableActor[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; + assignableUsers?: { + nodes: AssignableActor[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; + }; +} + export async function makeGitHubAPIRequest( fetcherService: IFetcherService, logService: ILogService, @@ -399,3 +425,125 @@ export async function makeGitHubAPIRequestWithPagination( return sessionInfos; } + +/** + * Fetches assignable actors (users/bots) for a repository using suggestedActors API. + * This is the preferred API as it filters by capability (CAN_BE_ASSIGNED). + */ +export async function getAssignableActorsWithSuggestedActors( + fetcherService: IFetcherService, + logService: ILogService, + telemetry: ITelemetryService, + host: string, + token: string | undefined, + owner: string, + repo: string, +): Promise { + const query = ` + query GetSuggestedActors($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + suggestedActors( + first: $first + after: $after + capabilities: [CAN_BE_ASSIGNED] + ) { + nodes { + __typename + login + avatarUrl + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + + const actors: AssignableActor[] = []; + let after: string | null = null; + let hasNextPage = true; + + while (hasNextPage) { + const variables = { + owner, + name: repo, + first: 100, + after, + }; + + const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables); + + if (!result?.data?.repository?.suggestedActors) { + break; + } + + const data = result.data.repository.suggestedActors; + actors.push(...data.nodes); + hasNextPage = data.pageInfo.hasNextPage; + after = data.pageInfo.endCursor; + } + + return actors; +} + +/** + * Fetches assignable users for a repository using assignableUsers API. + * This is a fallback for older GitHub Enterprise Server instances that don't support suggestedActors. + */ +export async function getAssignableActorsWithAssignableUsers( + fetcherService: IFetcherService, + logService: ILogService, + telemetry: ITelemetryService, + host: string, + token: string | undefined, + owner: string, + repo: string, +): Promise { + const query = ` + query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + assignableUsers(first: $first, after: $after) { + nodes { + __typename + login + avatarUrl + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + + const actors: AssignableActor[] = []; + let after: string | null = null; + let hasNextPage = true; + + while (hasNextPage) { + const variables = { + owner, + name: repo, + first: 100, + after, + }; + + const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables); + + if (!result?.data?.repository?.assignableUsers) { + break; + } + + const data = result.data.repository.assignableUsers; + actors.push(...data.nodes); + hasNextPage = data.pageInfo.hasNextPage; + after = data.pageInfo.endCursor; + } + + return actors; +} diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index b716c0b473..fc49000730 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -11,7 +11,7 @@ import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { addPullRequestCommentGraphQLRequest, closePullRequest, getPullRequestFromGlobalId, makeGitHubAPIRequest, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; +import { addPullRequestCommentGraphQLRequest, AssignableActor, closePullRequest, getPullRequestFromGlobalId, makeGitHubAPIRequest, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; /** * Options for controlling authentication behavior in OctoKit service methods. @@ -349,6 +349,16 @@ export interface IOctoKitService { * @returns An array of available models. The first model is always 'Auto' and should be the default. */ getCopilotAgentModels(authOptions: AuthOptions): Promise; + + /** + * Gets the list of assignable actors (users/bots) for a repository. + * This is used to check if partner agents like Copilot are available for assignment. + * @param owner The repository owner + * @param repo The repository name + * @param authOptions - Authentication options. By default, uses silent auth and returns empty array if not authenticated. + * @returns An array of assignable actors with their login names + */ + getAssignableActors(owner: string, repo: string, authOptions: AuthOptions): Promise; } /** @@ -360,9 +370,9 @@ export interface IOctoKitService { export class BaseOctoKitService { constructor( protected readonly _capiClientService: ICAPIClientService, - private readonly _fetcherService: IFetcherService, + protected readonly _fetcherService: IFetcherService, protected readonly _logService: ILogService, - private readonly _telemetryService: ITelemetryService + protected readonly _telemetryService: ITelemetryService ) { } async getCurrentAuthedUserWithToken(token: string): Promise { diff --git a/src/platform/github/common/octoKitServiceImpl.ts b/src/platform/github/common/octoKitServiceImpl.ts index 07f4643e48..83e6c129dc 100644 --- a/src/platform/github/common/octoKitServiceImpl.ts +++ b/src/platform/github/common/octoKitServiceImpl.ts @@ -8,7 +8,7 @@ import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; +import { AssignableActor, getAssignableActorsWithAssignableUsers, getAssignableActorsWithSuggestedActors, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; import { BaseOctoKitService, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService'; export class OctoKitService extends BaseOctoKitService implements IOctoKitService { @@ -374,4 +374,44 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic return []; } } + + async getAssignableActors(owner: string, repo: string, authOptions: { createIfNone?: boolean }): Promise { + const auth = (await this._authService.getGitHubSession('any', authOptions.createIfNone ? { createIfNone: true } : { silent: true })); + if (!auth?.accessToken) { + this._logService.trace('No authentication token available for getAssignableActors'); + return []; + } + + try { + // Try suggestedActors first (preferred API) + const actors = await getAssignableActorsWithSuggestedActors( + this._fetcherService, + this._logService, + this._telemetryService, + this._capiClientService.dotcomAPIURL, + auth.accessToken, + owner, + repo + ); + + if (actors.length > 0) { + return actors; + } + + // Fall back to assignableUsers for older GitHub Enterprise Server instances + this._logService.trace('Falling back to assignableUsers API'); + return await getAssignableActorsWithAssignableUsers( + this._fetcherService, + this._logService, + this._telemetryService, + this._capiClientService.dotcomAPIURL, + auth.accessToken, + owner, + repo + ); + } catch (e) { + this._logService.error(`Error fetching assignable actors: ${e}`); + return []; + } + } } diff --git a/src/platform/github/common/test/githubAPI.spec.ts b/src/platform/github/common/test/githubAPI.spec.ts new file mode 100644 index 0000000000..d9a1d3d358 --- /dev/null +++ b/src/platform/github/common/test/githubAPI.spec.ts @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { ILogService } from '../../../log/common/logService'; +import { FetchOptions, IFetcherService, Response } from '../../../networking/common/fetcherService'; +import { ITelemetryService } from '../../../telemetry/common/telemetry'; +import { createFakeResponse, FakeHeaders } from '../../../test/node/fetcher'; +import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; +import { + AssignableActor, + getAssignableActorsWithAssignableUsers, + getAssignableActorsWithSuggestedActors, +} from '../githubAPI'; + +describe('GitHub API - getAssignableActorsWithSuggestedActors', () => { + let accessor: ITestingServicesAccessor; + let disposables: DisposableStore; + let logService: ILogService; + let telemetryService: ITelemetryService; + + beforeEach(() => { + disposables = new DisposableStore(); + accessor = disposables.add(createPlatformServices().createTestingAccessor()); + logService = accessor.get(ILogService); + telemetryService = accessor.get(ITelemetryService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('should successfully retrieve actors with suggestedActors API', async () => { + const mockResponse = { + data: { + repository: { + suggestedActors: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + { __typename: 'Bot', login: 'bot1', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/apps/bot1' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const fetcher = new MockFetcherService([mockResponse]); + const actors = await getAssignableActorsWithSuggestedActors( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(2); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('bot1'); + expect(fetcher.fetchCallCount).toBe(1); + }); + + it('should handle pagination correctly', async () => { + const firstResponse = { + data: { + repository: { + suggestedActors: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor1', + }, + }, + }, + }, + }; + + const secondResponse = { + data: { + repository: { + suggestedActors: { + nodes: [ + { __typename: 'User', login: 'user2', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/user2' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const fetcher = new MockFetcherService([firstResponse, secondResponse]); + const actors = await getAssignableActorsWithSuggestedActors( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(2); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('user2'); + expect(fetcher.fetchCallCount).toBe(2); + }); + + it('should return empty array when no suggestedActors field', async () => { + const mockResponse = { + data: { + repository: {}, + }, + }; + + const fetcher = new MockFetcherService([mockResponse]); + const actors = await getAssignableActorsWithSuggestedActors( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(0); + }); + + it('should return empty array when API returns no data', async () => { + const fetcher = new MockFetcherService([undefined]); + const actors = await getAssignableActorsWithSuggestedActors( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(0); + }); + + it('should throw error when fetcher fails', async () => { + const fetcher = new FailingFetcherService(); + + await expect( + getAssignableActorsWithSuggestedActors( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ) + ).rejects.toThrow('Network error'); + }); +}); + +describe('GitHub API - getAssignableActorsWithAssignableUsers', () => { + let accessor: ITestingServicesAccessor; + let disposables: DisposableStore; + let logService: ILogService; + let telemetryService: ITelemetryService; + + beforeEach(() => { + disposables = new DisposableStore(); + accessor = disposables.add(createPlatformServices().createTestingAccessor()); + logService = accessor.get(ILogService); + telemetryService = accessor.get(ITelemetryService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('should successfully retrieve actors with assignableUsers API', async () => { + const mockResponse = { + data: { + repository: { + assignableUsers: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + { __typename: 'User', login: 'user2', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/user2' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const fetcher = new MockFetcherService([mockResponse]); + const actors = await getAssignableActorsWithAssignableUsers( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(2); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('user2'); + expect(fetcher.fetchCallCount).toBe(1); + }); + + it('should handle pagination correctly', async () => { + const firstResponse = { + data: { + repository: { + assignableUsers: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + { __typename: 'User', login: 'user2', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/user2' }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor1', + }, + }, + }, + }, + }; + + const secondResponse = { + data: { + repository: { + assignableUsers: { + nodes: [ + { __typename: 'User', login: 'user3', avatarUrl: 'https://example.com/avatar3', url: 'https://github.com/user3' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const fetcher = new MockFetcherService([firstResponse, secondResponse]); + const actors = await getAssignableActorsWithAssignableUsers( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(3); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('user2'); + expect(actors[2].login).toBe('user3'); + expect(fetcher.fetchCallCount).toBe(2); + }); + + it('should return empty array when no assignableUsers field', async () => { + const mockResponse = { + data: { + repository: {}, + }, + }; + + const fetcher = new MockFetcherService([mockResponse]); + const actors = await getAssignableActorsWithAssignableUsers( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(0); + }); + + it('should return empty array when API returns no data', async () => { + const fetcher = new MockFetcherService([undefined]); + const actors = await getAssignableActorsWithAssignableUsers( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ); + + expect(actors).toHaveLength(0); + }); + + it('should throw error when fetcher fails', async () => { + const fetcher = new FailingFetcherService(); + + await expect( + getAssignableActorsWithAssignableUsers( + fetcher, + logService, + telemetryService, + 'https://api.github.com', + 'test-token', + 'owner', + 'repo' + ) + ).rejects.toThrow('Network error'); + }); +}); + +// Mock FetcherService that returns predefined responses +class MockFetcherService implements Partial { + public fetchCallCount = 0; + private responseIndex = 0; + + constructor(private readonly responses: any[]) {} + + getUserAgentLibrary(): string { + return 'test'; + } + + async fetch(url: string, options?: FetchOptions): Promise { + this.fetchCallCount++; + const response = this.responses[this.responseIndex]; + this.responseIndex++; + + const headers = new FakeHeaders(); + headers['x-ratelimit-remaining'] = '5000'; + + return new Response( + 200, + 'OK', + headers, + async () => JSON.stringify(response), + async () => response, + async () => null, + 'test-stub' + ); + } +} + +// Fetcher service that simulates API failures +class FailingFetcherService implements Partial { + getUserAgentLibrary(): string { + return 'test'; + } + + async fetch(url: string, options?: FetchOptions): Promise { + throw new Error('Network error'); + } +} diff --git a/src/platform/github/common/test/octoKitServiceImpl.spec.ts b/src/platform/github/common/test/octoKitServiceImpl.spec.ts new file mode 100644 index 0000000000..895871d59b --- /dev/null +++ b/src/platform/github/common/test/octoKitServiceImpl.spec.ts @@ -0,0 +1,295 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { IAuthenticationService } from '../../../authentication/common/authentication'; +import { ICAPIClientService } from '../../../endpoint/common/capiClient'; +import { ILogService } from '../../../log/common/logService'; +import { FetchOptions, IFetcherService, Response } from '../../../networking/common/fetcherService'; +import { ITelemetryService } from '../../../telemetry/common/telemetry'; +import { FakeHeaders } from '../../../test/node/fetcher'; +import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; +import { AssignableActor } from '../githubAPI'; +import { OctoKitService } from '../octoKitServiceImpl'; + +describe('OctoKitService - getAssignableActors', () => { + let accessor: ITestingServicesAccessor; + let disposables: DisposableStore; + + beforeEach(() => { + disposables = new DisposableStore(); + }); + + afterEach(() => { + accessor?.dispose(); + disposables.dispose(); + }); + + it('should return empty array when no authentication token available', async () => { + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService(null); + testingServiceCollection.define(IAuthenticationService, mockAuthService); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(0); + }); + + it('should successfully retrieve actors using suggestedActors API', async () => { + const mockResponse = { + data: { + repository: { + suggestedActors: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + { __typename: 'Bot', login: 'copilot', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/apps/copilot' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new MockFetcherServiceForOctoKit([mockResponse]); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(2); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('copilot'); + expect(mockFetcher.fetchCallCount).toBe(1); + }); + + it('should fallback to assignableUsers when suggestedActors returns empty', async () => { + const emptySuggestedActorsResponse = { + data: { + repository: { + suggestedActors: { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const assignableUsersResponse = { + data: { + repository: { + assignableUsers: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + { __typename: 'User', login: 'user2', avatarUrl: 'https://example.com/avatar2', url: 'https://github.com/user2' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new MockFetcherServiceForOctoKit([emptySuggestedActorsResponse, assignableUsersResponse]); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(2); + expect(actors[0].login).toBe('user1'); + expect(actors[1].login).toBe('user2'); + expect(mockFetcher.fetchCallCount).toBe(2); // Called both APIs + }); + + it('should fallback to assignableUsers when suggestedActors API not supported', async () => { + const noSuggestedActorsResponse = { + data: { + repository: {}, + }, + }; + + const assignableUsersResponse = { + data: { + repository: { + assignableUsers: { + nodes: [ + { __typename: 'User', login: 'ghes-user', avatarUrl: 'https://example.com/avatar1', url: 'https://ghes.example.com/ghes-user' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new MockFetcherServiceForOctoKit([noSuggestedActorsResponse, assignableUsersResponse]); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(1); + expect(actors[0].login).toBe('ghes-user'); + expect(mockFetcher.fetchCallCount).toBe(2); + }); + + it('should handle errors gracefully and return empty array', async () => { + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new FailingFetcherServiceForOctoKit(); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(0); + }); + + it('should handle GraphQL API errors and return empty array', async () => { + const errorResponse = { + errors: [{ message: 'API rate limit exceeded' }], + }; + + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new MockFetcherServiceForOctoKit([errorResponse]); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: false }); + + expect(actors).toHaveLength(0); + }); + + it('should handle authentication with createIfNone option', async () => { + const mockResponse = { + data: { + repository: { + suggestedActors: { + nodes: [ + { __typename: 'User', login: 'user1', avatarUrl: 'https://example.com/avatar1', url: 'https://github.com/user1' }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }; + + const testingServiceCollection = createPlatformServices(); + const mockAuthService = new MockAuthenticationService({ accessToken: 'test-token', account: { id: 'test-user', label: 'Test User' } }); + const mockFetcher = new MockFetcherServiceForOctoKit([mockResponse]); + + testingServiceCollection.define(IAuthenticationService, mockAuthService); + testingServiceCollection.define(IFetcherService, mockFetcher); + accessor = testingServiceCollection.createTestingAccessor(); + + const octoKitService = accessor.get(IInstantiationService).createInstance(OctoKitService); + + const actors = await octoKitService.getAssignableActors('owner', 'repo', { createIfNone: true }); + + expect(actors).toHaveLength(1); + expect(mockAuthService.createIfNoneCalled).toBe(true); + }); +}); + +// Mock Authentication Service +class MockAuthenticationService implements Partial { + public createIfNoneCalled = false; + + constructor(private readonly session: { accessToken: string; account: { id: string; label: string } } | null) {} + + async getGitHubSession(mode: 'any' | 'permissive', options?: { createIfNone?: boolean; silent?: boolean }) { + if (options?.createIfNone) { + this.createIfNoneCalled = true; + } + return this.session; + } +} + +// Mock FetcherService for OctoKitService tests +class MockFetcherServiceForOctoKit implements Partial { + public fetchCallCount = 0; + private responseIndex = 0; + + constructor(private readonly responses: any[]) {} + + getUserAgentLibrary(): string { + return 'test'; + } + + async fetch(url: string, options?: FetchOptions): Promise { + this.fetchCallCount++; + const response = this.responses[this.responseIndex]; + this.responseIndex++; + + const headers = new FakeHeaders(); + headers['x-ratelimit-remaining'] = '5000'; + + return new Response( + 200, + 'OK', + headers, + async () => JSON.stringify(response), + async () => response, + async () => null, + 'test-stub' + ); + } +} + +// Failing Fetcher service for error testing +class FailingFetcherServiceForOctoKit implements Partial { + getUserAgentLibrary(): string { + return 'test'; + } + + async fetch(url: string, options?: FetchOptions): Promise { + throw new Error('Network error'); + } +}