Skip to content
Draft
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
27 changes: 22 additions & 5 deletions src/platform/github/common/githubAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);

Expand Down
132 changes: 132 additions & 0 deletions src/platform/github/common/githubAPIValidators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*---------------------------------------------------------------------------------------------
* 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';
import type { IOctoKitUser, RemoteAgentJobResponse, CustomAgentListItem, ErrorResponseWithStatusCode, JobInfo } from './githubService';

// Validator for SessionInfo
export const vSessionInfo: IValidator<SessionInfo> = 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<null>()),
resource_global_id: vRequired(vString()),
});

// Validator for PullRequestFile
export const vPullRequestFile: IValidator<PullRequestFile> = 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()),
});

// Validator for IOctoKitUser
export const vIOctoKitUser: IValidator<IOctoKitUser> = vObj({
login: vRequired(vString()),
name: vUnion(vString(), vUnchecked<null>()),
avatar_url: vRequired(vString()),
});

// Validator for RemoteAgentJobResponse
export const vRemoteAgentJobResponse: IValidator<RemoteAgentJobResponse> = 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<CustomAgentListItem> = 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<ErrorResponseWithStatusCode> = 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<JobInfo> = 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()),
});
111 changes: 101 additions & 10 deletions src/platform/github/common/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, vIOctoKitUser, vRemoteAgentJobOrError, vGetCustomAgentsResponse, vSessionInfo, vJobInfo } from './githubAPIValidators';

export type IGetRepositoryInfoResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"];

Expand Down Expand Up @@ -277,7 +279,16 @@ export class BaseOctoKitService {
) { }

async getCurrentAuthedUserWithToken(token: string): Promise<IOctoKitUser | undefined> {
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<any | undefined> {
Expand All @@ -294,27 +305,80 @@ 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) {
return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/${sessionId}/logs`, 'GET', token, undefined, undefined, 'text');
}

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<JobInfo> {
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<JobInfo> {
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<PullRequestComment | null> {
Expand All @@ -331,12 +395,29 @@ export class BaseOctoKitService {

protected async getCustomAgentsWithToken(owner: string, repo: string, token: string): Promise<GetCustomAgentsResponse> {
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<PullRequestFile[]> {
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<boolean> {
Expand All @@ -346,8 +427,18 @@ export class BaseOctoKitService {
protected async getFileContentWithToken(owner: string, repo: string, ref: string, path: string, token: string): Promise<string> {
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 '';
}
Expand Down
Loading