From 7d70a5f4e7e6b24c4dd987ff46c4dfe9bccf115b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:04:24 +0000 Subject: [PATCH 1/3] Initial plan From 8123d786629b6af83c4f0d6cf2591126a92e5067 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:14:31 +0000 Subject: [PATCH 2/3] Add validators for GitHub API responses Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> --- src/platform/github/common/githubAPI.ts | 27 ++- .../github/common/githubAPIValidators.ts | 57 +++++ src/platform/github/common/githubService.ts | 26 ++- .../test/common/githubAPIValidators.spec.ts | 202 ++++++++++++++++++ 4 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/platform/github/common/githubAPIValidators.ts create mode 100644 src/platform/github/test/common/githubAPIValidators.spec.ts diff --git a/src/platform/github/common/githubAPI.ts b/src/platform/github/common/githubAPI.ts index 44dc8f7a82..8b94a05616 100644 --- a/src/platform/github/common/githubAPI.ts +++ b/src/platform/github/common/githubAPI.ts @@ -6,6 +6,7 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; +import { vSessionsResponse, vPullRequestStateResponse } from './githubAPIValidators'; export interface PullRequestSearchItem { id: string; @@ -353,11 +354,22 @@ export async function closePullRequest( '2022-11-28' ); - const success = result?.state === 'closed'; + if (!result) { + logService.error(`[GitHubAPI] Failed to close pull request ${owner}/${repo}#${pullNumber}. No response received`); + return false; + } + + const validationResult = vPullRequestStateResponse.validate(result); + if (validationResult.error) { + logService.error(`[GitHubAPI] Failed to validate pull request response: ${validationResult.error.message}`); + return false; + } + + const success = validationResult.content.state === 'closed'; if (success) { logService.debug(`[GitHubAPI] Successfully closed pull request ${owner}/${repo}#${pullNumber}`); } else { - logService.error(`[GitHubAPI] Failed to close pull request ${owner}/${repo}#${pullNumber}. Its state is ${result?.state}`); + logService.error(`[GitHubAPI] Failed to close pull request ${owner}/${repo}#${pullNumber}. Its state is ${validationResult.content.state}`); } return success; } @@ -387,9 +399,14 @@ export async function makeGitHubAPIRequestWithPagination( logService.error(`[GitHubAPI] Failed to fetch sessions: ${response.status} ${response.statusText}`); return sessionInfos; } - const sessions = await response.json(); - sessionInfos.push(...sessions.sessions); - hasNextPage = sessions.sessions.length === page_size; + const untrustedSessions = await response.json(); + const validationResult = vSessionsResponse.validate(untrustedSessions); + if (validationResult.error) { + logService.error(`[GitHubAPI] Failed to validate sessions response: ${validationResult.error.message}`); + return sessionInfos; + } + sessionInfos.push(...validationResult.content.sessions); + hasNextPage = validationResult.content.sessions.length === page_size; page++; } while (hasNextPage); diff --git a/src/platform/github/common/githubAPIValidators.ts b/src/platform/github/common/githubAPIValidators.ts new file mode 100644 index 0000000000..82823a525a --- /dev/null +++ b/src/platform/github/common/githubAPIValidators.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IValidator, vArray, vNumber, vObj, vRequired, vString, vUnion, vUnchecked } from '../../configuration/common/validator'; +import type { SessionInfo, PullRequestFile } from './githubAPI'; + +// Validator for SessionInfo +export const vSessionInfo: IValidator = vObj({ + id: vRequired(vString()), + name: vRequired(vString()), + user_id: vRequired(vNumber()), + agent_id: vRequired(vNumber()), + logs: vRequired(vString()), + logs_blob_id: vRequired(vString()), + state: vRequired(vString()), + owner_id: vRequired(vNumber()), + repo_id: vRequired(vNumber()), + resource_type: vRequired(vString()), + resource_id: vRequired(vNumber()), + last_updated_at: vRequired(vString()), + created_at: vRequired(vString()), + completed_at: vRequired(vString()), + event_type: vRequired(vString()), + workflow_run_id: vRequired(vNumber()), + premium_requests: vRequired(vNumber()), + error: vUnion(vString(), vUnchecked()), + resource_global_id: vRequired(vString()), +}); + +// Validator for PullRequestFile +export const vPullRequestFile: IValidator = vObj({ + filename: vRequired(vString()), + status: vRequired(vString()), + additions: vRequired(vNumber()), + deletions: vRequired(vNumber()), + changes: vRequired(vNumber()), + patch: vString(), + previous_filename: vString(), +}); + +// Validator for sessions response with pagination +export const vSessionsResponse = vObj({ + sessions: vRequired(vArray(vSessionInfo)), +}); + +// Validator for file content response +export const vFileContentResponse = vObj({ + content: vRequired(vString()), + encoding: vRequired(vString()), +}); + +// Validator for pull request state response +export const vPullRequestStateResponse = vObj({ + state: vRequired(vString()), +}); diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index e5378428ab..d84fffd25e 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -6,11 +6,13 @@ import type { Endpoints } from "@octokit/types"; import { createServiceIdentifier } from '../../../util/common/services'; import { decodeBase64 } from '../../../util/vs/base/common/buffer'; +import { vArray } from '../../configuration/common/validator'; 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, makeGitHubAPIRequestWithPagination, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; +import { vFileContentResponse, vPullRequestFile } from './githubAPIValidators'; export type IGetRepositoryInfoResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"]; @@ -336,7 +338,15 @@ export class BaseOctoKitService { protected async getPullRequestFilesWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise { const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, `repos/${owner}/${repo}/pulls/${pullNumber}/files`, 'GET', token, undefined, '2022-11-28'); - return result || []; + if (!result) { + return []; + } + const validationResult = vArray(vPullRequestFile).validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate pull request files response: ${validationResult.error.message}`); + return []; + } + return validationResult.content; } protected async closePullRequestWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise { @@ -346,8 +356,18 @@ export class BaseOctoKitService { protected async getFileContentWithToken(owner: string, repo: string, ref: string, path: string, token: string): Promise { const response = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, `repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, 'GET', token, undefined); - if (response?.content && response.encoding === 'base64') { - return decodeBase64(response.content.replace(/\n/g, '')).toString(); + if (!response) { + return ''; + } + + const validationResult = vFileContentResponse.validate(response); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate file content response: ${validationResult.error.message}`); + return ''; + } + + if (validationResult.content.encoding === 'base64') { + return decodeBase64(validationResult.content.content.replace(/\n/g, '')).toString(); } else { return ''; } diff --git a/src/platform/github/test/common/githubAPIValidators.spec.ts b/src/platform/github/test/common/githubAPIValidators.spec.ts new file mode 100644 index 0000000000..1023422589 --- /dev/null +++ b/src/platform/github/test/common/githubAPIValidators.spec.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { vSessionInfo, vPullRequestFile, vSessionsResponse, vFileContentResponse, vPullRequestStateResponse } from '../../common/githubAPIValidators'; + +describe('vSessionInfo', () => { + it('should validate a valid SessionInfo object', () => { + const validSession = { + id: 'session-123', + name: 'Test Session', + user_id: 12345, + agent_id: 67890, + logs: 'Log content', + logs_blob_id: 'blob-123', + state: 'completed', + owner_id: 11111, + repo_id: 22222, + resource_type: 'pull_request', + resource_id: 33333, + last_updated_at: '2024-01-01T00:00:00Z', + created_at: '2024-01-01T00:00:00Z', + completed_at: '2024-01-01T00:00:00Z', + event_type: 'push', + workflow_run_id: 44444, + premium_requests: 5, + error: null, + resource_global_id: 'global-123', + }; + + const result = vSessionInfo.validate(validSession); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validSession); + }); + + it('should reject a SessionInfo object with missing required fields', () => { + const invalidSession = { + id: 'session-123', + name: 'Test Session', + // Missing required fields + }; + + const result = vSessionInfo.validate(invalidSession); + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("Required field"); + }); + + it('should reject a SessionInfo object with wrong types', () => { + const invalidSession = { + id: 'session-123', + name: 'Test Session', + user_id: '12345', // should be number + agent_id: 67890, + logs: 'Log content', + logs_blob_id: 'blob-123', + state: 'completed', + owner_id: 11111, + repo_id: 22222, + resource_type: 'pull_request', + resource_id: 33333, + last_updated_at: '2024-01-01T00:00:00Z', + created_at: '2024-01-01T00:00:00Z', + completed_at: '2024-01-01T00:00:00Z', + event_type: 'push', + workflow_run_id: 44444, + premium_requests: 5, + error: null, + resource_global_id: 'global-123', + }; + + const result = vSessionInfo.validate(invalidSession); + expect(result.error).toBeDefined(); + }); +}); + +describe('vPullRequestFile', () => { + it('should validate a valid PullRequestFile object', () => { + const validFile = { + filename: 'test.ts', + status: 'modified', + additions: 10, + deletions: 5, + changes: 15, + patch: '@@ -1,5 +1,5 @@', + }; + + const result = vPullRequestFile.validate(validFile); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validFile); + }); + + it('should validate a PullRequestFile without optional fields', () => { + const validFile = { + filename: 'test.ts', + status: 'added', + additions: 10, + deletions: 0, + changes: 10, + }; + + const result = vPullRequestFile.validate(validFile); + expect(result.error).toBeUndefined(); + expect(result.content?.filename).toBe('test.ts'); + }); + + it('should reject a PullRequestFile with missing required fields', () => { + const invalidFile = { + filename: 'test.ts', + // Missing required fields + }; + + const result = vPullRequestFile.validate(invalidFile); + expect(result.error).toBeDefined(); + }); +}); + +describe('vSessionsResponse', () => { + it('should validate a valid sessions response', () => { + const validResponse = { + sessions: [ + { + id: 'session-123', + name: 'Test Session', + user_id: 12345, + agent_id: 67890, + logs: 'Log content', + logs_blob_id: 'blob-123', + state: 'completed', + owner_id: 11111, + repo_id: 22222, + resource_type: 'pull_request', + resource_id: 33333, + last_updated_at: '2024-01-01T00:00:00Z', + created_at: '2024-01-01T00:00:00Z', + completed_at: '2024-01-01T00:00:00Z', + event_type: 'push', + workflow_run_id: 44444, + premium_requests: 5, + error: null, + resource_global_id: 'global-123', + }, + ], + }; + + const result = vSessionsResponse.validate(validResponse); + expect(result.error).toBeUndefined(); + expect(result.content?.sessions).toHaveLength(1); + }); + + it('should reject invalid sessions response', () => { + const invalidResponse = { + // Missing sessions array + }; + + const result = vSessionsResponse.validate(invalidResponse); + expect(result.error).toBeDefined(); + }); +}); + +describe('vFileContentResponse', () => { + it('should validate a valid file content response', () => { + const validResponse = { + content: 'SGVsbG8gV29ybGQ=', + encoding: 'base64', + }; + + const result = vFileContentResponse.validate(validResponse); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validResponse); + }); + + it('should reject file content response with missing fields', () => { + const invalidResponse = { + content: 'SGVsbG8gV29ybGQ=', + // Missing encoding + }; + + const result = vFileContentResponse.validate(invalidResponse); + expect(result.error).toBeDefined(); + }); +}); + +describe('vPullRequestStateResponse', () => { + it('should validate a valid pull request state response', () => { + const validResponse = { + state: 'closed', + }; + + const result = vPullRequestStateResponse.validate(validResponse); + expect(result.error).toBeUndefined(); + expect(result.content?.state).toBe('closed'); + }); + + it('should reject pull request state response without state field', () => { + const invalidResponse = {}; + + const result = vPullRequestStateResponse.validate(invalidResponse); + expect(result.error).toBeDefined(); + }); +}); From 17c317eb9dda25eaf2a3b0ef23eaaafa7ca17a56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:20:59 +0000 Subject: [PATCH 3/3] Add comprehensive validation for all GitHub API responses Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> --- .../github/common/githubAPIValidators.ts | 75 +++++++++ src/platform/github/common/githubService.ts | 87 +++++++++- .../test/common/githubAPIValidators.spec.ts | 150 +++++++++++++++++- 3 files changed, 303 insertions(+), 9 deletions(-) diff --git a/src/platform/github/common/githubAPIValidators.ts b/src/platform/github/common/githubAPIValidators.ts index 82823a525a..a8a74b7a61 100644 --- a/src/platform/github/common/githubAPIValidators.ts +++ b/src/platform/github/common/githubAPIValidators.ts @@ -5,6 +5,7 @@ import { IValidator, vArray, vNumber, vObj, vRequired, vString, vUnion, vUnchecked } from '../../configuration/common/validator'; import type { SessionInfo, PullRequestFile } from './githubAPI'; +import type { IOctoKitUser, RemoteAgentJobResponse, CustomAgentListItem, ErrorResponseWithStatusCode, JobInfo } from './githubService'; // Validator for SessionInfo export const vSessionInfo: IValidator = vObj({ @@ -55,3 +56,77 @@ export const vFileContentResponse = vObj({ export const vPullRequestStateResponse = vObj({ state: vRequired(vString()), }); + +// Validator for IOctoKitUser +export const vIOctoKitUser: IValidator = vObj({ + login: vRequired(vString()), + name: vUnion(vString(), vUnchecked()), + avatar_url: vRequired(vString()), +}); + +// Validator for RemoteAgentJobResponse +export const vRemoteAgentJobResponse: IValidator = vObj({ + job_id: vRequired(vString()), + session_id: vRequired(vString()), + actor: vRequired(vObj({ + id: vRequired(vNumber()), + login: vRequired(vString()), + })), + created_at: vRequired(vString()), + updated_at: vRequired(vString()), +}); + +// Validator for CustomAgentListItem +export const vCustomAgentListItem: IValidator = vObj({ + name: vRequired(vString()), + repo_owner_id: vRequired(vNumber()), + repo_owner: vRequired(vString()), + repo_id: vRequired(vNumber()), + repo_name: vRequired(vString()), + display_name: vRequired(vString()), + description: vRequired(vString()), + tools: vRequired(vArray(vString())), + version: vRequired(vString()), +}); + +// Validator for GetCustomAgentsResponse +export const vGetCustomAgentsResponse = vObj({ + agents: vRequired(vArray(vCustomAgentListItem)), +}); + +// Validator for ErrorResponseWithStatusCode +export const vErrorResponseWithStatusCode: IValidator = vObj({ + status: vRequired(vNumber()), +}); + +// Validator for job responses that could be either RemoteAgentJobResponse or ErrorResponseWithStatusCode +export const vRemoteAgentJobOrError = vUnion(vRemoteAgentJobResponse, vErrorResponseWithStatusCode); + +// Validator for JobInfo +export const vJobInfo: IValidator = vObj({ + job_id: vRequired(vString()), + session_id: vRequired(vString()), + problem_statement: vRequired(vString()), + content_filter_mode: vString(), + status: vRequired(vString()), + result: vString(), + actor: vRequired(vObj({ + id: vRequired(vNumber()), + login: vRequired(vString()), + })), + created_at: vRequired(vString()), + updated_at: vRequired(vString()), + pull_request: vRequired(vObj({ + id: vRequired(vNumber()), + number: vRequired(vNumber()), + })), + workflow_run: vObj({ + id: vRequired(vNumber()), + }), + error: vObj({ + message: vRequired(vString()), + }), + event_type: vString(), + event_url: vString(), + event_identifiers: vArray(vString()), +}); diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index d84fffd25e..e94579b3f0 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -12,7 +12,7 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { addPullRequestCommentGraphQLRequest, closePullRequest, getPullRequestFromGlobalId, makeGitHubAPIRequest, makeGitHubAPIRequestWithPagination, makeSearchGraphQLRequest, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; -import { vFileContentResponse, vPullRequestFile } from './githubAPIValidators'; +import { vFileContentResponse, vPullRequestFile, vIOctoKitUser, vRemoteAgentJobOrError, vGetCustomAgentsResponse, vSessionInfo, vJobInfo } from './githubAPIValidators'; export type IGetRepositoryInfoResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"]; @@ -279,7 +279,16 @@ export class BaseOctoKitService { ) { } async getCurrentAuthedUserWithToken(token: string): Promise { - return this._makeGHAPIRequest('user', 'GET', token); + const result = await this._makeGHAPIRequest('user', 'GET', token); + if (!result) { + return undefined; + } + const validationResult = vIOctoKitUser.validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate authenticated user response: ${validationResult.error.message}`); + return undefined; + } + return validationResult.content; } async getTeamMembershipWithToken(teamId: number, token: string, username: string): Promise { @@ -296,7 +305,16 @@ export class BaseOctoKitService { } protected async getCopilotSessionsForPRWithToken(prId: string, token: string) { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/resource/pull/${prId}`, 'GET', token); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/resource/pull/${prId}`, 'GET', token); + if (!result) { + return []; + } + const validationResult = vArray(vSessionInfo).validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate sessions for PR response: ${validationResult.error.message}`); + return []; + } + return validationResult.content; } protected async getSessionLogsWithToken(sessionId: string, token: string) { @@ -304,19 +322,63 @@ export class BaseOctoKitService { } protected async getSessionInfoWithToken(sessionId: string, token: string) { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/${sessionId}`, 'GET', token, undefined, undefined, 'text'); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/${sessionId}`, 'GET', token, undefined, undefined, 'text'); + if (!result) { + return undefined; + } + // The response is text, so we need to parse it as JSON first + let parsed: unknown; + try { + parsed = typeof result === 'string' ? JSON.parse(result) : result; + } catch (error) { + this._logService.error(`[GitHubAPI] Failed to parse session info response as JSON: ${error}`); + return undefined; + } + const validationResult = vSessionInfo.validate(parsed); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate session info response: ${validationResult.error.message}`); + return undefined; + } + return validationResult.content; } protected async postCopilotAgentJobWithToken(owner: string, name: string, apiVersion: string, userAgent: string, payload: RemoteAgentJobPayload, token: string) { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/${apiVersion}/jobs/${owner}/${name}`, 'POST', token, payload, undefined, undefined, userAgent, true); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/${apiVersion}/jobs/${owner}/${name}`, 'POST', token, payload, undefined, undefined, userAgent, true); + if (!result) { + return undefined; + } + const validationResult = vRemoteAgentJobOrError.validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate remote agent job response: ${validationResult.error.message}`); + return undefined; + } + return validationResult.content; } protected async getJobByJobIdWithToken(owner: string, repo: string, jobId: string, userAgent: string, token: string): Promise { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, 'GET', token, undefined, undefined, undefined, userAgent); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, 'GET', token, undefined, undefined, undefined, userAgent); + if (!result) { + throw new Error('Failed to fetch job info: No response received'); + } + const validationResult = vJobInfo.validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate job info response: ${validationResult.error.message}`); + throw new Error(`Failed to validate job info: ${validationResult.error.message}`); + } + return validationResult.content; } protected async getJobBySessionIdWithToken(owner: string, repo: string, sessionId: string, userAgent: string, token: string): Promise { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/v1/jobs/${owner}/${repo}/session/${sessionId}`, 'GET', token, undefined, undefined, undefined, userAgent); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/v1/jobs/${owner}/${repo}/session/${sessionId}`, 'GET', token, undefined, undefined, undefined, userAgent); + if (!result) { + throw new Error('Failed to fetch job info: No response received'); + } + const validationResult = vJobInfo.validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate job info response: ${validationResult.error.message}`); + throw new Error(`Failed to validate job info: ${validationResult.error.message}`); + } + return validationResult.content; } protected async addPullRequestCommentWithToken(pullRequestId: string, commentBody: string, token: string): Promise { @@ -333,7 +395,16 @@ export class BaseOctoKitService { protected async getCustomAgentsWithToken(owner: string, repo: string, token: string): Promise { const queryParams = '?exclude_invalid_config=true'; - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/custom-agents/${owner}/${repo}${queryParams}`, 'GET', token, undefined, undefined, 'json', 'vscode-copilot-chat'); + const result = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/custom-agents/${owner}/${repo}${queryParams}`, 'GET', token, undefined, undefined, 'json', 'vscode-copilot-chat'); + if (!result) { + return { agents: [] }; + } + const validationResult = vGetCustomAgentsResponse.validate(result); + if (validationResult.error) { + this._logService.error(`[GitHubAPI] Failed to validate custom agents response: ${validationResult.error.message}`); + return { agents: [] }; + } + return validationResult.content; } protected async getPullRequestFilesWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise { diff --git a/src/platform/github/test/common/githubAPIValidators.spec.ts b/src/platform/github/test/common/githubAPIValidators.spec.ts index 1023422589..9f0a783b0e 100644 --- a/src/platform/github/test/common/githubAPIValidators.spec.ts +++ b/src/platform/github/test/common/githubAPIValidators.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from 'vitest'; -import { vSessionInfo, vPullRequestFile, vSessionsResponse, vFileContentResponse, vPullRequestStateResponse } from '../../common/githubAPIValidators'; +import { vSessionInfo, vPullRequestFile, vSessionsResponse, vFileContentResponse, vPullRequestStateResponse, vIOctoKitUser, vRemoteAgentJobResponse, vCustomAgentListItem, vGetCustomAgentsResponse, vJobInfo } from '../../common/githubAPIValidators'; describe('vSessionInfo', () => { it('should validate a valid SessionInfo object', () => { @@ -200,3 +200,151 @@ describe('vPullRequestStateResponse', () => { expect(result.error).toBeDefined(); }); }); + +describe('vIOctoKitUser', () => { + it('should validate a valid IOctoKitUser object', () => { + const validUser = { + login: 'testuser', + name: 'Test User', + avatar_url: 'https://example.com/avatar.png', + }; + + const result = vIOctoKitUser.validate(validUser); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validUser); + }); + + it('should validate IOctoKitUser with null name', () => { + const validUser = { + login: 'testuser', + name: null, + avatar_url: 'https://example.com/avatar.png', + }; + + const result = vIOctoKitUser.validate(validUser); + expect(result.error).toBeUndefined(); + expect(result.content?.name).toBeNull(); + }); +}); + +describe('vRemoteAgentJobResponse', () => { + it('should validate a valid RemoteAgentJobResponse object', () => { + const validResponse = { + job_id: 'job-123', + session_id: 'session-456', + actor: { + id: 789, + login: 'testactor', + }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + const result = vRemoteAgentJobResponse.validate(validResponse); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validResponse); + }); +}); + +describe('vCustomAgentListItem', () => { + it('should validate a valid CustomAgentListItem object', () => { + const validAgent = { + name: 'test-agent', + repo_owner_id: 123, + repo_owner: 'testowner', + repo_id: 456, + repo_name: 'testrepo', + display_name: 'Test Agent', + description: 'A test agent', + tools: ['tool1', 'tool2'], + version: '1.0.0', + }; + + const result = vCustomAgentListItem.validate(validAgent); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validAgent); + }); +}); + +describe('vGetCustomAgentsResponse', () => { + it('should validate a valid GetCustomAgentsResponse object', () => { + const validResponse = { + agents: [ + { + name: 'test-agent', + repo_owner_id: 123, + repo_owner: 'testowner', + repo_id: 456, + repo_name: 'testrepo', + display_name: 'Test Agent', + description: 'A test agent', + tools: ['tool1', 'tool2'], + version: '1.0.0', + }, + ], + }; + + const result = vGetCustomAgentsResponse.validate(validResponse); + expect(result.error).toBeUndefined(); + expect(result.content?.agents).toHaveLength(1); + }); +}); + +describe('vJobInfo', () => { + it('should validate a valid JobInfo object', () => { + const validJob = { + job_id: 'job-123', + session_id: 'session-456', + problem_statement: 'Fix the bug', + status: 'completed', + actor: { + id: 789, + login: 'testactor', + }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + pull_request: { + id: 111, + number: 222, + }, + }; + + const result = vJobInfo.validate(validJob); + expect(result.error).toBeUndefined(); + expect(result.content).toEqual(validJob); + }); + + it('should validate JobInfo with optional fields', () => { + const validJob = { + job_id: 'job-123', + session_id: 'session-456', + problem_statement: 'Fix the bug', + content_filter_mode: 'strict', + status: 'completed', + result: 'Success', + actor: { + id: 789, + login: 'testactor', + }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + pull_request: { + id: 111, + number: 222, + }, + workflow_run: { + id: 333, + }, + error: { + message: 'Some error', + }, + event_type: 'push', + event_url: 'https://example.com/event', + event_identifiers: ['id1', 'id2'], + }; + + const result = vJobInfo.validate(validJob); + expect(result.error).toBeUndefined(); + expect(result.content?.content_filter_mode).toBe('strict'); + }); +});