From 89d912cc7146403b4a9ac34c0671b37bd09c3ee7 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 14 Oct 2025 19:34:25 -0700 Subject: [PATCH] Copilot cloud agents provider (#1333) * Copilot cloud agents provider * Missing file --------- Co-authored-by: Peng Lyu --- extensions/copilot/package.json | 10 + .../chatSessions/vscode-node/chatSessions.ts | 13 +- .../copilotChatSessionContentBuilder.ts | 554 ++++++++++++++++++ .../copilotChatSessionsProvider.ts | 99 ++++ .../vscode/chatSessionsUriHandler.ts | 17 + .../src/platform/github/common/githubAPI.ts | 161 ++++- .../platform/github/common/githubService.ts | 64 +- .../github/common/octoKitServiceImpl.ts | 40 ++ 8 files changed, 950 insertions(+), 8 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode/chatSessionsUriHandler.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2058ac2174c..1048926081c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4178,6 +4178,16 @@ "supportsFileAttachments": true, "supportsToolAttachments": false } + }, + { + "type": "copilot-cloud-agent", + "name": "copilot", + "displayName": "GitHub Copilot cloud agent", + "description": "Delegate tasks to the GitHub Copilot coding agent. The agent works asynchronously to implement changes, iterates via chat, and can create or update pull requests as needed.", + "when": "config.github.copilot.chat.advanced.copilotCodingAgent.enabled", + "capabilities": { + "supportsFileAttachments": true + } } ], "debuggers": [ diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 8ea67d3dbe8..bb54999a6e6 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { OctoKitService } from '../../../platform/github/common/octoKitServiceImpl'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; @@ -19,6 +21,7 @@ import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProv import { ClaudeChatSessionItemProvider } from './claudeChatSessionItemProvider'; import { ClaudeChatSessionParticipant } from './claudeChatSessionParticipant'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessionsContribution'; +import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider'; export class ChatSessionsContrib extends Disposable implements IExtensionContribution { readonly id = 'chatSessions'; @@ -63,5 +66,13 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); this._register(registerCLIChatCommands(copilotcliSessionItemProvider, copilotCLISessionService)); + + // Copilot sessions provider + const copilotAgentInstaService = instantiationService.createChild(new ServiceCollection( + [IOctoKitService, new SyncDescriptor(OctoKitService)], + )); + const copilotSessionsProvider = this._register(copilotAgentInstaService.createInstance(CopilotChatSessionsProvider)); + this._register(vscode.chat.registerChatSessionItemProvider(CopilotChatSessionsProvider.TYPE, copilotSessionsProvider)); + this._register(vscode.chat.registerChatSessionContentProvider(CopilotChatSessionsProvider.TYPE, copilotSessionsProvider, undefined as any)); } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts new file mode 100644 index 00000000000..35cff42d7d8 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts @@ -0,0 +1,554 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseMultiDiffPart, ChatResponseProgressPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatResult, ChatToolInvocationPart, MarkdownString, Uri } from 'vscode'; +import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; + +export interface SessionResponseLogChunk { + choices: Array<{ + finish_reason?: 'tool_calls' | 'null' | (string & {}); + delta: { + content?: string; + role: 'assistant' | (string & {}); + tool_calls?: Array<{ + function: { + arguments: string; + name: string; + }; + id: string; + type: string; + index: number; + }>; + }; + }>; + created: number; + id: string; + usage: { + completion_tokens: number; + prompt_tokens: number; + prompt_tokens_details: { + cached_tokens: number; + }; + total_tokens: number; + }; + model: string; + object: string; +} + +export interface ToolCall { + function: { + arguments: string; + name: 'bash' | 'reply_to_comment' | (string & {}); + }; + id: string; + type: string; + index: number; +} + +export interface AssistantDelta { + content?: string; + role: 'assistant' | (string & {}); + tool_calls?: ToolCall[]; +} + +export interface Choice { + finish_reason?: 'tool_calls' | (string & {}); + delta: { + content?: string; + role: 'assistant' | (string & {}); + tool_calls?: ToolCall[]; + }; +} + +export interface StrReplaceEditorToolData { + command: 'view' | 'edit' | string; + filePath?: string; + fileLabel?: string; + parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined }; + viewRange?: { start: number; end: number }; +} + +export namespace StrReplaceEditorToolData { + export function is(value: any): value is StrReplaceEditorToolData { + return value && (typeof value.command === 'string'); + } +} + +export interface BashToolData { + commandLine: { + original: string; + }; + language: 'bash'; +} + +export interface ParsedToolCallDetails { + toolName: string; + invocationMessage: string; + pastTenseMessage?: string; + originMessage?: string; + toolSpecificData?: StrReplaceEditorToolData | BashToolData; +} + +export class ChatSessionContentBuilder { + constructor( + private type: string, + private getLogsForSession: (id: string) => Promise, + ) { } + + public async buildSessionHistory( + sessions: SessionInfo[], + pullRequest: PullRequestSearchItem, + ): Promise> { + const sortedSessions = sessions + .filter((session, index, array) => + array.findIndex(s => s.id === session.id) === index + ) + .slice().sort((a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + const history: Array = []; + + // Process all sessions concurrently while maintaining order + await Promise.all( + sortedSessions.map(async (session, sessionIndex) => { + const logs = await this.getLogsForSession(session.id); + // Create response turn + const response = await this.createResponseTurn(pullRequest, logs, session); + history.push(new ChatRequestTurn2( + '', + undefined, // command + [], // references + this.type, + [], // toolReferences + [] + )); + if (response) { + history.push(response); + } + }) + ); + + return history; + } + + private async createResponseTurn(pullRequest: PullRequestSearchItem, logs: string, session: SessionInfo): Promise { + if (logs.trim().length > 0) { + return await this.parseSessionLogsIntoResponseTurn(pullRequest, logs, session); + } else if (session.state === 'in_progress') { + // For in-progress sessions without logs, create a placeholder response + const placeholderParts = [new ChatResponseProgressPart('Session is initializing...')]; + const responseResult: ChatResult = {}; + return new ChatResponseTurn2(placeholderParts, responseResult, this.type); + } else { + // For completed sessions without logs, add an empty response to maintain pairing + const emptyParts = [new ChatResponseMarkdownPart('_No logs available for this session_')]; + const responseResult: ChatResult = {}; + return new ChatResponseTurn2(emptyParts, responseResult, this.type); + } + } + + private async parseSessionLogsIntoResponseTurn(pullRequest: PullRequestSearchItem, logs: string, session: SessionInfo): Promise { + try { + const logChunks = this.parseSessionLogs(logs); + const responseParts: Array = []; + + for (const chunk of logChunks) { + if (!chunk.choices || !Array.isArray(chunk.choices)) { + continue; + } + + for (const choice of chunk.choices) { + const delta = choice.delta; + if (delta.role === 'assistant') { + this.processAssistantDelta(delta, choice, pullRequest, responseParts); + } + + } + } + + if (session.state === 'completed' || session.state === 'failed' /** session can fail with proposed changes */) { + // TODO: we don't have a way to render multidiff yet + // const fileChangesPart = await this.getFileChangesMultiDiffPart(pullRequest); + // if (fileChangesPart) { + // responseParts.push(fileChangesPart); + // } + } + + if (responseParts.length > 0) { + const responseResult: ChatResult = {}; + return new ChatResponseTurn2(responseParts, responseResult, this.type); + } + + return undefined; + } catch (error) { + return undefined; + } + } + + private parseSessionLogs(rawText: string): SessionResponseLogChunk[] { + const parts = rawText + .split(/\r?\n/) + .filter(part => part.startsWith('data: ')) + .map(part => part.slice('data: '.length).trim()) + .map(part => JSON.parse(part)); + + return parts as SessionResponseLogChunk[]; + } + + private processAssistantDelta( + delta: AssistantDelta, + choice: Choice, + pullRequest: PullRequestSearchItem, + responseParts: Array, + ): string { + let currentResponseContent = ''; + if (delta.role === 'assistant') { + // Handle special case for run_custom_setup_step + if ( + choice.finish_reason === 'tool_calls' && + delta.tool_calls?.length && + (delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup') + ) { + const toolCall = delta.tool_calls[0]; + let args: { name?: string } = {}; + try { + args = JSON.parse(toolCall.function.arguments); + } catch { + // fallback to empty args + } + + if (delta.content && delta.content.trim()) { + const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content); + if (toolPart) { + responseParts.push(toolPart); + } + } + // Skip if content is empty (running state) + } else { + if (delta.content) { + if (!delta.content.startsWith('') && !delta.content.startsWith('')) { + currentResponseContent += delta.content; + } + } + + const isError = delta.content?.startsWith(''); + if (delta.tool_calls) { + // Add any accumulated content as markdown first + if (currentResponseContent.trim()) { + responseParts.push(new ChatResponseMarkdownPart(currentResponseContent.trim())); + currentResponseContent = ''; + } + + for (const toolCall of delta.tool_calls) { + const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || ''); + if (toolPart) { + responseParts.push(toolPart); + } + } + + if (isError) { + const toolPart = new ChatToolInvocationPart('Command', 'command'); + // Remove at the start and at the end + const cleaned = (delta.content ?? '').replace(/^\s*\s*/i, '').replace(/\s*<\/error>\s*$/i, ''); + toolPart.invocationMessage = cleaned; + toolPart.isError = true; + responseParts.push(toolPart); + } + } + } + } + return currentResponseContent; + } + + private createToolInvocationPart(pullRequest: PullRequestSearchItem, toolCall: ToolCall, deltaContent: string = ''): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined { + if (!toolCall.function?.name || !toolCall.id) { + return undefined; + } + + // Hide reply_to_comment tool + if (toolCall.function.name === 'reply_to_comment') { + return undefined; + } + + const toolPart = new ChatToolInvocationPart(toolCall.function.name, toolCall.id); + toolPart.isComplete = true; + toolPart.isError = false; + toolPart.isConfirmed = true; + + try { + const toolDetails = this.parseToolCallDetails(toolCall, deltaContent); + toolPart.toolName = toolDetails.toolName; + + if (toolPart.toolName === 'think') { + return new ChatResponseThinkingProgressPart(toolDetails.invocationMessage); + } + + if (toolCall.function.name === 'bash') { + toolPart.invocationMessage = new MarkdownString(`\`\`\`bash\n${toolDetails.invocationMessage}\n\`\`\``); + } else { + toolPart.invocationMessage = new MarkdownString(toolDetails.invocationMessage); + } + + if (toolDetails.pastTenseMessage) { + toolPart.pastTenseMessage = new MarkdownString(toolDetails.pastTenseMessage); + } + if (toolDetails.originMessage) { + toolPart.originMessage = new MarkdownString(toolDetails.originMessage); + } + if (toolDetails.toolSpecificData) { + if (StrReplaceEditorToolData.is(toolDetails.toolSpecificData)) { + if ((toolDetails.toolSpecificData.command === 'view' || toolDetails.toolSpecificData.command === 'edit') && toolDetails.toolSpecificData.fileLabel) { + // TODO: handle file paths correctly + const uri = Uri.file(toolDetails.toolSpecificData.fileLabel); + toolPart.invocationMessage = new MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : '')); + toolPart.invocationMessage.supportHtml = true; + toolPart.pastTenseMessage = new MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : '')); + } + } else { + toolPart.toolSpecificData = toolDetails.toolSpecificData; + } + } + } catch (error) { + toolPart.toolName = toolCall.function.name || 'unknown'; + toolPart.invocationMessage = new MarkdownString(`Tool: ${toolCall.function.name}`); + toolPart.isError = true; + } + + return toolPart; + } + + /** + * Convert absolute file path to relative file label + * File paths are absolute and look like: `/home/runner/work/repo/repo/` + */ + private toFileLabel(file: string): string { + const parts = file.split('/'); + return parts.slice(6).join('/'); + } + + private parseRange(view_range: unknown): { start: number; end: number } | undefined { + if (!view_range) { + return undefined; + } + + if (!Array.isArray(view_range)) { + return undefined; + } + + if (view_range.length !== 2) { + return undefined; + } + + const start = view_range[0]; + const end = view_range[1]; + + if (typeof start !== 'number' || typeof end !== 'number') { + return undefined; + } + + return { + start, + end + }; + } + + /** + * Parse diff content and extract file information + */ + private parseDiff(content: string): { content: string; fileA: string | undefined; fileB: string | undefined } | undefined { + const lines = content.split(/\r?\n/g); + let fileA: string | undefined; + let fileB: string | undefined; + + let startDiffLineIndex = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('diff --git')) { + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (match) { + fileA = match[1]; + fileB = match[2]; + } + } else if (line.startsWith('@@ ')) { + startDiffLineIndex = i + 1; + break; + } + } + if (startDiffLineIndex < 0) { + return undefined; + } + + return { + content: lines.slice(startDiffLineIndex).join('\n'), + fileA: typeof fileA === 'string' ? '/' + fileA : undefined, + fileB: typeof fileB === 'string' ? '/' + fileB : undefined + }; + } + + /** + * Parse tool call arguments and return normalized tool details + */ + private parseToolCallDetails( + toolCall: { + function: { name: string; arguments: string }; + id: string; + type: string; + index: number; + }, + content: string + ): ParsedToolCallDetails { + // Parse arguments once with graceful fallback + let args: { command?: string; path?: string; prDescription?: string; commitMessage?: string; view_range?: unknown } = {}; + try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { /* ignore */ } + + const name = toolCall.function.name; + + // Small focused helpers to remove duplication while preserving behavior + const buildReadDetails = (filePath: string | undefined, parsedRange: { start: number; end: number } | undefined, opts?: { parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined } }): ParsedToolCallDetails => { + const fileLabel = filePath && this.toFileLabel(filePath); + if (fileLabel === undefined || fileLabel === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + // Default helper returns bracket variant (used for generic view). Plain variant handled separately for str_replace_editor non-diff. + return { + toolName: 'Read', + invocationMessage: `Read [](${fileLabel})${rangeSuffix}`, + pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`, + toolSpecificData: { + command: 'view', + filePath: filePath, + fileLabel: fileLabel, + parsedContent: opts?.parsedContent, + viewRange: parsedRange + } + }; + }; + + const buildEditDetails = (filePath: string | undefined, command: string, parsedRange: { start: number; end: number } | undefined, opts?: { defaultName?: string }): ParsedToolCallDetails => { + const fileLabel = filePath && this.toFileLabel(filePath); + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + let invocationMessage: string; + let pastTenseMessage: string; + if (fileLabel) { + invocationMessage = `Edit [](${fileLabel})${rangeSuffix}`; + pastTenseMessage = `Edit [](${fileLabel})${rangeSuffix}`; + } else { + if (opts?.defaultName === 'Create') { + invocationMessage = pastTenseMessage = `Create File ${filePath}`; + } else { + invocationMessage = pastTenseMessage = (opts?.defaultName || 'Edit'); + } + invocationMessage += rangeSuffix; + pastTenseMessage += rangeSuffix; + } + + return { + toolName: opts?.defaultName || 'Edit', + invocationMessage, + pastTenseMessage, + toolSpecificData: fileLabel ? { + command: command || (opts?.defaultName === 'Create' ? 'create' : (command || 'edit')), + filePath: filePath, + fileLabel: fileLabel, + viewRange: parsedRange + } : undefined + }; + }; + + const buildStrReplaceDetails = (filePath: string | undefined): ParsedToolCallDetails => { + const fileLabel = filePath && this.toFileLabel(filePath); + const message = fileLabel ? `Edit [](${fileLabel})` : `Edit ${filePath}`; + return { + toolName: 'Edit', + invocationMessage: message, + pastTenseMessage: message, + toolSpecificData: fileLabel ? { command: 'str_replace', filePath, fileLabel } : undefined + }; + }; + + const buildCreateDetails = (filePath: string | undefined): ParsedToolCallDetails => { + const fileLabel = filePath && this.toFileLabel(filePath); + const message = fileLabel ? `Create [](${fileLabel})` : `Create File ${filePath}`; + return { + toolName: 'Create', + invocationMessage: message, + pastTenseMessage: message, + toolSpecificData: fileLabel ? { command: 'create', filePath, fileLabel } : undefined + }; + }; + + const buildBashDetails = (bashArgs: typeof args, contentStr: string): ParsedToolCallDetails => { + const command = bashArgs.command ? `$ ${bashArgs.command}` : undefined; + const bashContent = [command, contentStr].filter(Boolean).join('\n'); + const details: ParsedToolCallDetails = { toolName: 'Run Bash command', invocationMessage: bashContent || 'Run Bash command' }; + if (bashArgs.command) { details.toolSpecificData = { commandLine: { original: bashArgs.command }, language: 'bash' }; } + return details; + }; + + switch (name) { + case 'str_replace_editor': { + if (args.command === 'view') { + const parsedContent = this.parseDiff(content); + const parsedRange = this.parseRange(args.view_range); + if (parsedContent) { + const file = parsedContent.fileA ?? parsedContent.fileB; + const fileLabel = file && this.toFileLabel(file); + if (fileLabel === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } else if (fileLabel === undefined) { + return { toolName: 'Read', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } else { + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + return { + toolName: 'Read', + invocationMessage: `Read [](${fileLabel})${rangeSuffix}`, + pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`, + toolSpecificData: { command: 'view', filePath: file, fileLabel, parsedContent, viewRange: parsedRange } + }; + } + } + // No diff parsed: use PLAIN (non-bracket) variant for str_replace_editor views + const plainRange = this.parseRange(args.view_range); + const fp = args.path; const fl = fp && this.toFileLabel(fp); + if (fl === undefined || fl === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } + const suffix = plainRange ? `, lines ${plainRange.start} to ${plainRange.end}` : ''; + return { + toolName: 'Read', + invocationMessage: `Read ${fl}${suffix}`, + pastTenseMessage: `Read ${fl}${suffix}`, + toolSpecificData: { command: 'view', filePath: fp, fileLabel: fl, viewRange: plainRange } + }; + } + return buildEditDetails(args.path, args.command || 'edit', this.parseRange(args.view_range)); + } + case 'str_replace': + return buildStrReplaceDetails(args.path); + case 'create': + return buildCreateDetails(args.path); + case 'view': + return buildReadDetails(args.path, this.parseRange(args.view_range)); // generic view always bracket variant + case 'think': { + const thought = (args as unknown as { thought?: string }).thought || content || 'Thought'; + return { toolName: 'think', invocationMessage: thought }; + } + case 'report_progress': { + const details: ParsedToolCallDetails = { toolName: 'Progress Update', invocationMessage: `${args.prDescription}` || content || 'Progress Update' }; + if (args.commitMessage) { details.originMessage = `Commit: ${args.commitMessage}`; } + return details; + } + case 'bash': + return buildBashDetails(args, content); + case 'read_bash': + return { toolName: 'read_bash', invocationMessage: 'Read logs from Bash session' }; + case 'stop_bash': + return { toolName: 'stop_bash', invocationMessage: 'Stop Bash session' }; + default: + return { toolName: name || 'unknown', invocationMessage: content || name || 'unknown' }; + } + } +} \ No newline at end of file diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts new file mode 100644 index 00000000000..323625de7cb --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getGithubRepoIdFromFetchUrl, IGitService } from '../../../platform/git/common/gitService'; +import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; +import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { UriHandlerPaths, UriHandlers } from '../vscode/chatSessionsUriHandler'; +import { ChatSessionContentBuilder } from './copilotChatSessionContentBuilder'; + +export class CopilotChatSessionsProvider extends Disposable implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider { + public static readonly TYPE = 'copilot-cloud-agent'; + private readonly _onDidChangeChatSessionItems = this._register(new vscode.EventEmitter()); + public onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + private readonly _onDidCommitChatSessionItem = this._register(new vscode.EventEmitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); + public onDidCommitChatSessionItem = this._onDidCommitChatSessionItem.event; + private chatSessions: Map = new Map(); + + constructor( + @IOctoKitService private readonly _octoKitService: IOctoKitService, + @IGitService private readonly _gitService: IGitService, + ) { + super(); + } + + async provideChatSessionItems(token: vscode.CancellationToken): Promise { + // TODO: Return same promise if fetching the chat session items multiple times + const repo = this._gitService.activeRepository.get(); + if (!repo || !repo.remoteFetchUrls?.[0]) { + return []; + } + const repoId = getGithubRepoIdFromFetchUrl(repo.remoteFetchUrls[0]); + if (!repoId) { + return []; + } + const pullRequests = await this._octoKitService.getCopilotPullRequestsForUser(repoId.org, repoId.repo); + const sessionItems = await Promise.all(pullRequests.map(async pr => { + const uri = await this.toOpenPullRequestWebviewUri({ owner: pr.repository.owner.login, repo: pr.repository.name, pullRequestNumber: pr.number }); + const prLinkTitle = vscode.l10n.t('Open pull request in VS Code'); + const description = new vscode.MarkdownString(`[#${pr.number}](${uri.toString()} "${prLinkTitle}")`); + const session = { + id: pr.number.toString(), + label: pr.title, + status: this.getSessionState(pr.state), + description, + timing: { + startTime: new Date(pr.updatedAt).getTime(), + }, + statistics: { + insertions: pr.additions, + deletions: pr.deletions + }, + fullDatabaseId: pr.fullDatabaseId.toString(), + }; + this.chatSessions.set(pr.number, pr); + return session; + })); + return sessionItems; + } + + async provideChatSessionContent(sessionId: string, token: vscode.CancellationToken): Promise { + const pr = this.chatSessions.get(Number(sessionId)); + if (!pr) { + throw new Error(`Session not found for ID: ${sessionId}`); + } + const sessions = await this._octoKitService.getCopilotSessionsForPR(pr.fullDatabaseId.toString()); + const sessionContentBuilder = new ChatSessionContentBuilder(CopilotChatSessionsProvider.TYPE, (sessionId: string) => this._octoKitService.getSessionLogs(sessionId)); + const history = await sessionContentBuilder.buildSessionHistory(sessions, pr); + return { + history, + activeResponseCallback: async () => { }, + requestHandler: undefined + }; + } + + private getSessionState(state: string): vscode.ChatSessionStatus { + switch (state) { + case 'failed': + return vscode.ChatSessionStatus.Failed; + case 'in_progress': case 'queued': + return vscode.ChatSessionStatus.InProgress; + default: + return vscode.ChatSessionStatus.Completed; + } + } + + private async toOpenPullRequestWebviewUri(params: { + owner: string; + repo: string; + pullRequestNumber: number; + }): Promise { + const query = JSON.stringify(params); + const extensionId = UriHandlers[UriHandlerPaths.External_OpenPullRequestWebview]; + return await vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: extensionId, path: UriHandlerPaths.External_OpenPullRequestWebview, query })); + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode/chatSessionsUriHandler.ts b/extensions/copilot/src/extension/chatSessions/vscode/chatSessionsUriHandler.ts new file mode 100644 index 00000000000..ecc41b58239 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode/chatSessionsUriHandler.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EXTENSION_ID } from '../../common/constants'; + +const GHPR_EXTENSION_ID = 'GitHub.vscode-pull-request-github'; +export enum UriHandlerPaths { + OpenSessionPullRequest = '/open-session-pull-request', + External_OpenPullRequestWebview = '/open-pull-request-webview', +} + +export const UriHandlers = { + [UriHandlerPaths.OpenSessionPullRequest]: EXTENSION_ID, + [UriHandlerPaths.External_OpenPullRequestWebview]: GHPR_EXTENSION_ID +}; diff --git a/extensions/copilot/src/platform/github/common/githubAPI.ts b/extensions/copilot/src/platform/github/common/githubAPI.ts index 32fd5d6d1f0..77cf7723840 100644 --- a/extensions/copilot/src/platform/github/common/githubAPI.ts +++ b/extensions/copilot/src/platform/github/common/githubAPI.ts @@ -7,15 +7,70 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; +export interface PullRequestSearchItem { + number: number; + title: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; + author: { + login: string; + } | null; + repository: { + owner: { + login: string; + }; + name: string; + }; + additions: number; + deletions: number; + fullDatabaseId: number; + headRefOid: number; +} -export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logService: ILogService, telemetry: ITelemetryService, host: string, routeSlug: string, method: 'GET' | 'POST', token: string | undefined, body?: { [key: string]: any }) { +export interface PullRequestSearchResult { + search: { + nodes: PullRequestSearchItem[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + issueCount: number; + }; +} + +export interface SessionInfo { + id: string; + name: string; + user_id: number; + agent_id: number; + logs: string; + logs_blob_id: string; + state: 'completed' | 'in_progress' | 'failed' | 'queued'; + owner_id: number; + repo_id: number; + resource_type: string; + resource_id: number; + last_updated_at: string; + created_at: string; + completed_at: string; + event_type: string; + workflow_run_id: number; + premium_requests: number; + error: string | null; +} + +export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logService: ILogService, telemetry: ITelemetryService, host: string, routeSlug: string, method: 'GET' | 'POST', token: string | undefined, body?: { [key: string]: any }, version?: string, type: 'json' | 'text' = 'json') { const headers: any = { 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } + if (version) { + headers['X-GitHub-Api-Version'] = version; + } const response = await fetcherService.fetch(`${host}/${routeSlug}`, { method, @@ -27,7 +82,7 @@ export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logS } try { - const result = await response.json(); + const result = type === 'json' ? await response.json() : await response.text(); const rateLimit = Number(response.headers.get('x-ratelimit-remaining')); const logMessage = `[RateLimit] REST rate limit remaining: ${rateLimit}, ${routeSlug}`; if (rateLimit < 1000) { @@ -41,4 +96,102 @@ export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logS } catch { return undefined; } -} \ No newline at end of file +} + +export async function makeGitHubGraphQLRequest(fetcherService: IFetcherService, logService: ILogService, telemetry: ITelemetryService, host: string, query: string, token: string | undefined, variables?: { [key: string]: any }) { + const headers: any = { + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const body = JSON.stringify({ + query, + variables + }); + + const response = await fetcherService.fetch(`${host}/graphql`, { + method: 'POST', + headers, + body + }); + + if (!response.ok) { + return undefined; + } + + try { + const result = await response.json(); + const rateLimit = Number(response.headers.get('x-ratelimit-remaining')); + const logMessage = `[RateLimit] GraphQL rate limit remaining: ${rateLimit}, query: ${query}`; + if (rateLimit < 1000) { + // Danger zone + logService.warn(logMessage); + telemetry.sendMSFTTelemetryEvent('githubAPI.approachingRateLimit', { rateLimit: rateLimit.toString() }); + } else { + logService.debug(logMessage); + } + return result; + } catch { + return undefined; + } +} + +export async function makeSearchGraphQLRequest( + fetcherService: IFetcherService, + logService: ILogService, + telemetry: ITelemetryService, + host: string, + token: string | undefined, + searchQuery: string, + first: number = 20, +): Promise { + const query = ` + query FetchCopilotAgentPullRequests($searchQuery: String!, $first: Int!, $after: String) { + search(query: $searchQuery, type: ISSUE, first: $first, after: $after) { + nodes { + ... on PullRequest { + number + id + fullDatabaseId + headRefOid + title + state + url + createdAt + updatedAt + additions + deletions + author { + login + } + repository { + owner { + login + } + name + } + } + } + pageInfo { + hasNextPage + endCursor + } + issueCount + } + } + `; + + logService.debug(`[FolderRepositoryManager+0] Fetch pull request category ${searchQuery}`); + + const variables = { + searchQuery, + first + }; + + const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables); + + return result ? result.data.search.nodes : []; +} diff --git a/extensions/copilot/src/platform/github/common/githubService.ts b/extensions/copilot/src/platform/github/common/githubService.ts index 8aca12ff27c..741f831e668 100644 --- a/extensions/copilot/src/platform/github/common/githubService.ts +++ b/extensions/copilot/src/platform/github/common/githubService.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { Endpoints } from "@octokit/types"; import { createServiceIdentifier } from '../../../util/common/services'; 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 { makeGitHubAPIRequest } from './githubAPI'; -import type { Endpoints } from "@octokit/types"; +import { makeGitHubAPIRequest, makeSearchGraphQLRequest, PullRequestSearchItem, SessionInfo } from './githubAPI'; export type IGetRepositoryInfoResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"]; @@ -46,6 +46,36 @@ export interface IOctoKitUser { avatar_url: string; } +export interface IOctoKitSessionInfo { + name: string; + owner_id: number; + premium_requests: number; + repo_id: number; + resource_global_id: string; + resource_id: number; + resource_state: string; + resource_type: string; + state: string; + user_id: number; + workflow_run_id: number; + last_updated_at: string; + created_at: string; +} + +export interface IOctoKitPullRequestInfo { + number: number; + title: string; + additions: number; + deletions: number; + headRepository: { + name: string; + owner: { + login: string; + }; + url: string; + }; +} + export interface IOctoKitService { _serviceBrand: undefined; @@ -60,6 +90,21 @@ export interface IOctoKitService { * @returns The team membership or undefined if the user is not a member of the team */ getTeamMembership(teamId: number): Promise; + + /** + * Returns the list of Copilot pull requests for a given user on a specific repo. + */ + getCopilotPullRequestsForUser(owner: string, repo: string): Promise; + + /** + * Returns the list of Copilot sessions for a given pull request. + */ + getCopilotSessionsForPR(prId: string): Promise; + + /** + * Returns the logs for a specific Copilot session. + */ + getSessionLogs(sessionId: string): Promise; } /** @@ -85,6 +130,19 @@ export class BaseOctoKitService { } protected async _makeGHAPIRequest(routeSlug: string, method: 'GET' | 'POST', token: string, body?: { [key: string]: any }) { - return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, routeSlug, method, token, body); + return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, routeSlug, method, token, body, '2022-11-28'); + } + + protected async getCopilotPullRequestForUserWithToken(owner: string, repo: string, user: string, token: string) { + const query = `repo:${owner}/${repo} is:open author:copilot-swe-agent[bot] involves:${user}`; + return makeSearchGraphQLRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, query); + } + + 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); + } + + 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'); } } diff --git a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts index 3ba5ab109f5..56e061d5995 100644 --- a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts +++ b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts @@ -7,6 +7,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 { PullRequestSearchItem, SessionInfo } from './githubAPI'; import { BaseOctoKitService, IOctoKitService, IOctoKitUser } from './githubService'; export class OctoKitService extends BaseOctoKitService implements IOctoKitService { @@ -39,4 +40,43 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } return await this.getTeamMembershipWithToken(teamId, token, username); } + + async getCopilotPullRequestsForUser(owner: string, repo: string): Promise { + const auth = (await this._authService.getAnyGitHubSession()); + if (!auth?.accessToken) { + return []; + } + const response = await this.getCopilotPullRequestForUserWithToken( + owner, + repo, + auth.account.label, + auth.accessToken, + ); + return response; + } + + async getCopilotSessionsForPR(prId: string): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + return []; + } + const response = await this.getCopilotSessionsForPRWithToken( + prId, + authToken, + ); + const { sessions } = response; + return sessions; + } + + async getSessionLogs(sessionId: string): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + return ''; + } + const response = await this.getSessionLogsWithToken( + sessionId, + authToken, + ); + return response; + } }