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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/extension/chatSessions/vscode-node/chatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ClaudeChatSessionParticipant } from './claudeChatSessionParticipant';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessionsContribution';
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
import { CopilotChatSessionsProvider } from './copilotCloudSessionsProvider';
import { PRContentProvider } from './prContentProvider';
import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService';
import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver';

Expand Down Expand Up @@ -135,6 +136,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
if (enabled && !this.copilotCloudRegistrations) {
// Register the Copilot Cloud chat participant
this.copilotCloudRegistrations = new DisposableStore();

this.copilotCloudRegistrations.add(
this.copilotAgentInstaService.createInstance(PRContentProvider)
);

const copilotSessionsProvider = this.copilotCloudRegistrations.add(
this.copilotAgentInstaService.createInstance(CopilotChatSessionsProvider)
);
Expand Down
110 changes: 110 additions & 0 deletions src/extension/chatSessions/vscode-node/prContentProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*---------------------------------------------------------------------------------------------
* 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 { IOctoKitService } from '../../../platform/github/common/githubService';
import { ILogService } from '../../../platform/log/common/logService';
import { Disposable } from '../../../util/vs/base/common/lifecycle';

/**
* URI scheme for PR content
*/
export const PR_SCHEME = 'copilot-pr';

/**
* Parameters encoded in PR content URIs
*/
export interface PRContentUriParams {
owner: string;
repo: string;
prNumber: number;
fileName: string;
commitSha: string;
isBase: boolean; // true for left side, false for right side
previousFileName?: string; // for renames
}

/**
* Create a URI for PR file content
*/
export function toPRContentUri(
fileName: string,
params: Omit<PRContentUriParams, 'fileName'>
): vscode.Uri {
return vscode.Uri.from({
scheme: PR_SCHEME,
path: `/${fileName}`,
query: JSON.stringify({ ...params, fileName })
});
}

/**
* Parse parameters from a PR content URI
*/
export function fromPRContentUri(uri: vscode.Uri): PRContentUriParams | undefined {
if (uri.scheme !== PR_SCHEME) {
return undefined;
}
try {
return JSON.parse(uri.query) as PRContentUriParams;
} catch (e) {
return undefined;
}
}

/**
* TextDocumentContentProvider for PR content that fetches file content from GitHub
*/
export class PRContentProvider extends Disposable implements vscode.TextDocumentContentProvider {
private static readonly ID = 'PRContentProvider';
private _onDidChange = this._register(new vscode.EventEmitter<vscode.Uri>());
readonly onDidChange = this._onDidChange.event;

constructor(
@IOctoKitService private readonly _octoKitService: IOctoKitService,
@ILogService private readonly logService: ILogService,
) {
super();

// Register text document content provider for PR scheme
this._register(
vscode.workspace.registerTextDocumentContentProvider(
PR_SCHEME,
this
)
);
}

async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
const params = fromPRContentUri(uri);
if (!params) {
this.logService.error(`[${PRContentProvider.ID}] Invalid PR content URI: ${uri.toString()}`);
return '';
}

try {
this.logService.trace(
`[${PRContentProvider.ID}] Fetching ${params.isBase ? 'base' : 'head'} content for ${params.fileName} ` +
`from ${params.owner}/${params.repo}#${params.prNumber} at ${params.commitSha}`
);

// Fetch file content from GitHub
const content = await this._octoKitService.getFileContent(
params.owner,
params.repo,
params.commitSha,
params.fileName
);

return content;
} catch (error) {
this.logService.error(
`[${PRContentProvider.ID}] Failed to fetch PR file content: ${error instanceof Error ? error.message : String(error)}`
);
// Return empty content instead of throwing to avoid breaking the diff view
return '';
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
import { IGitService } from '../../../platform/git/common/gitService';
import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI';
import { IOctoKitService } from '../../../platform/github/common/githubService';
import { ILogService } from '../../../platform/log/common/logService';
import { createServiceIdentifier } from '../../../util/common/services';
import { getRepoId } from '../vscode/copilotCodingAgentUtils';
import { toPRContentUri } from './prContentProvider';

export const IPullRequestFileChangesService = createServiceIdentifier<IPullRequestFileChangesService>('IPullRequestFileChangesService');

Expand All @@ -25,7 +25,6 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer
constructor(
@IGitService private readonly _gitService: IGitService,
@IOctoKitService private readonly _octoKitService: IOctoKitService,
@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,
@ILogService private readonly logService: ILogService,
) { }

Expand All @@ -47,34 +46,54 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer
return undefined;
}

const diffEntries: vscode.ChatResponseDiffEntry[] = [];
const git = this._gitExtensionService.getExtensionApi();
const repo = git?.repositories[0];
const workspaceRoot = repo?.rootUri;

if (!workspaceRoot) {
this.logService.warn('No workspace root found for file URIs');
// Check if we have base and head commit SHAs
if (!pullRequest.baseRefOid || !pullRequest.headRefOid) {
this.logService.warn('PR missing base or head commit SHA, cannot create diff URIs');
return undefined;
}

const diffEntries: vscode.ChatResponseDiffEntry[] = [];

for (const file of files) {
const fileUri = vscode.Uri.joinPath(workspaceRoot, file.filename);
const originalUri = file.previous_filename
? vscode.Uri.joinPath(workspaceRoot, file.previous_filename)
: fileUri;
// Always use remote URIs to ensure we show the exact PR content
// Local files may be on different branches or have different changes
this.logService.trace(`Creating remote URIs for ${file.filename}`);

const originalUri = toPRContentUri(
file.previous_filename || file.filename,
{
owner: repoId.org,
repo: repoId.repo,
prNumber: pullRequest.number,
commitSha: pullRequest.baseRefOid,
isBase: true,
previousFileName: file.previous_filename
}
);

const modifiedUri = toPRContentUri(
file.filename,
{
owner: repoId.org,
repo: repoId.repo,
prNumber: pullRequest.number,
commitSha: pullRequest.headRefOid,
isBase: false
}
);

this.logService.trace(`DiffEntry -> original='${originalUri.fsPath}' modified='${fileUri.fsPath}' (+${file.additions} -${file.deletions})`);
this.logService.trace(`DiffEntry -> original='${originalUri.toString()}' modified='${modifiedUri.toString()}' (+${file.additions} -${file.deletions})`);
diffEntries.push({
originalUri,
modifiedUri: fileUri,
goToFileUri: fileUri,
modifiedUri,
goToFileUri: modifiedUri,
added: file.additions,
removed: file.deletions,
});
}

const title = `Changes in Pull Request #${pullRequest.number}`;
return new vscode.ChatResponseMultiDiffPart(diffEntries, title, true /* readOnly */);
return new vscode.ChatResponseMultiDiffPart(diffEntries, title, false);
} catch (error) {
this.logService.error(`Failed to get file changes multi diff part: ${error}`);
return undefined;
Expand Down
5 changes: 4 additions & 1 deletion src/platform/github/common/githubAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export interface PullRequestSearchItem {
additions: number;
deletions: number;
fullDatabaseId: number;
headRefOid: number;
headRefOid: string;
baseRefOid?: string;
body: string;
}

Expand Down Expand Up @@ -184,6 +185,7 @@ export async function makeSearchGraphQLRequest(
id
fullDatabaseId
headRefOid
baseRefOid
title
state
url
Expand Down Expand Up @@ -240,6 +242,7 @@ export async function getPullRequestFromGlobalId(
id
fullDatabaseId
headRefOid
baseRefOid
title
state
url
Expand Down
21 changes: 21 additions & 0 deletions src/platform/github/common/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { Endpoints } from "@octokit/types";
import { createServiceIdentifier } from '../../../util/common/services';
import { decodeBase64 } from '../../../util/vs/base/common/buffer';
import { ICAPIClientService } from '../../endpoint/common/capiClient';
import { ILogService } from '../../log/common/logService';
import { IFetcherService } from '../../networking/common/fetcherService';
Expand Down Expand Up @@ -261,6 +262,16 @@ export interface IOctoKitService {
* @returns A promise that resolves to true if the PR was successfully closed
*/
closePullRequest(owner: string, repo: string, pullNumber: number): Promise<boolean>;

/**
* Get file content from a specific commit.
* @param owner The repository owner
* @param repo The repository name
* @param ref The commit SHA, branch name, or tag
* @param path The file path within the repository
* @returns The file content as a string
*/
getFileContent(owner: string, repo: string, ref: string, path: string): Promise<string>;
}

/**
Expand Down Expand Up @@ -348,4 +359,14 @@ export class BaseOctoKitService {
protected async closePullRequestWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise<boolean> {
return closePullRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, owner, repo, pullNumber);
}

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();
} else {
return '';
}
}
}
8 changes: 8 additions & 0 deletions src/platform/github/common/octoKitServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,12 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic
}
return this.closePullRequestWithToken(owner, repo, pullNumber, authToken);
}

async getFileContent(owner: string, repo: string, ref: string, path: string): Promise<string> {
const authToken = (await this._authService.getAnyGitHubSession())?.accessToken;
if (!authToken) {
throw new Error('No GitHub authentication available');
}
return this.getFileContentWithToken(owner, repo, ref, path, authToken);
}
}
Loading