Skip to content

Commit 2cedc2d

Browse files
authored
Support file content change part for cloud agent (#1641)
* Support file content change part for cloud agent * move off Buffer * 💄
1 parent 8ecb974 commit 2cedc2d

File tree

6 files changed

+185
-18
lines changed

6 files changed

+185
-18
lines changed

src/extension/chatSessions/vscode-node/chatSessions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ClaudeChatSessionParticipant } from './claudeChatSessionParticipant';
2626
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessionsContribution';
2727
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
2828
import { CopilotChatSessionsProvider } from './copilotCloudSessionsProvider';
29+
import { PRContentProvider } from './prContentProvider';
2930
import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService';
3031
import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver';
3132

@@ -135,6 +136,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
135136
if (enabled && !this.copilotCloudRegistrations) {
136137
// Register the Copilot Cloud chat participant
137138
this.copilotCloudRegistrations = new DisposableStore();
139+
140+
this.copilotCloudRegistrations.add(
141+
this.copilotAgentInstaService.createInstance(PRContentProvider)
142+
);
143+
138144
const copilotSessionsProvider = this.copilotCloudRegistrations.add(
139145
this.copilotAgentInstaService.createInstance(CopilotChatSessionsProvider)
140146
);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { IOctoKitService } from '../../../platform/github/common/githubService';
8+
import { ILogService } from '../../../platform/log/common/logService';
9+
import { Disposable } from '../../../util/vs/base/common/lifecycle';
10+
11+
/**
12+
* URI scheme for PR content
13+
*/
14+
export const PR_SCHEME = 'copilot-pr';
15+
16+
/**
17+
* Parameters encoded in PR content URIs
18+
*/
19+
export interface PRContentUriParams {
20+
owner: string;
21+
repo: string;
22+
prNumber: number;
23+
fileName: string;
24+
commitSha: string;
25+
isBase: boolean; // true for left side, false for right side
26+
previousFileName?: string; // for renames
27+
}
28+
29+
/**
30+
* Create a URI for PR file content
31+
*/
32+
export function toPRContentUri(
33+
fileName: string,
34+
params: Omit<PRContentUriParams, 'fileName'>
35+
): vscode.Uri {
36+
return vscode.Uri.from({
37+
scheme: PR_SCHEME,
38+
path: `/${fileName}`,
39+
query: JSON.stringify({ ...params, fileName })
40+
});
41+
}
42+
43+
/**
44+
* Parse parameters from a PR content URI
45+
*/
46+
export function fromPRContentUri(uri: vscode.Uri): PRContentUriParams | undefined {
47+
if (uri.scheme !== PR_SCHEME) {
48+
return undefined;
49+
}
50+
try {
51+
return JSON.parse(uri.query) as PRContentUriParams;
52+
} catch (e) {
53+
return undefined;
54+
}
55+
}
56+
57+
/**
58+
* TextDocumentContentProvider for PR content that fetches file content from GitHub
59+
*/
60+
export class PRContentProvider extends Disposable implements vscode.TextDocumentContentProvider {
61+
private static readonly ID = 'PRContentProvider';
62+
private _onDidChange = this._register(new vscode.EventEmitter<vscode.Uri>());
63+
readonly onDidChange = this._onDidChange.event;
64+
65+
constructor(
66+
@IOctoKitService private readonly _octoKitService: IOctoKitService,
67+
@ILogService private readonly logService: ILogService,
68+
) {
69+
super();
70+
71+
// Register text document content provider for PR scheme
72+
this._register(
73+
vscode.workspace.registerTextDocumentContentProvider(
74+
PR_SCHEME,
75+
this
76+
)
77+
);
78+
}
79+
80+
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
81+
const params = fromPRContentUri(uri);
82+
if (!params) {
83+
this.logService.error(`[${PRContentProvider.ID}] Invalid PR content URI: ${uri.toString()}`);
84+
return '';
85+
}
86+
87+
try {
88+
this.logService.trace(
89+
`[${PRContentProvider.ID}] Fetching ${params.isBase ? 'base' : 'head'} content for ${params.fileName} ` +
90+
`from ${params.owner}/${params.repo}#${params.prNumber} at ${params.commitSha}`
91+
);
92+
93+
// Fetch file content from GitHub
94+
const content = await this._octoKitService.getFileContent(
95+
params.owner,
96+
params.repo,
97+
params.commitSha,
98+
params.fileName
99+
);
100+
101+
return content;
102+
} catch (error) {
103+
this.logService.error(
104+
`[${PRContentProvider.ID}] Failed to fetch PR file content: ${error instanceof Error ? error.message : String(error)}`
105+
);
106+
// Return empty content instead of throwing to avoid breaking the diff view
107+
return '';
108+
}
109+
}
110+
}

src/extension/chatSessions/vscode-node/pullRequestFileChangesService.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7-
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
87
import { IGitService } from '../../../platform/git/common/gitService';
98
import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI';
109
import { IOctoKitService } from '../../../platform/github/common/githubService';
1110
import { ILogService } from '../../../platform/log/common/logService';
1211
import { createServiceIdentifier } from '../../../util/common/services';
1312
import { getRepoId } from '../vscode/copilotCodingAgentUtils';
13+
import { toPRContentUri } from './prContentProvider';
1414

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

@@ -25,7 +25,6 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer
2525
constructor(
2626
@IGitService private readonly _gitService: IGitService,
2727
@IOctoKitService private readonly _octoKitService: IOctoKitService,
28-
@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,
2928
@ILogService private readonly logService: ILogService,
3029
) { }
3130

@@ -47,34 +46,54 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer
4746
return undefined;
4847
}
4948

50-
const diffEntries: vscode.ChatResponseDiffEntry[] = [];
51-
const git = this._gitExtensionService.getExtensionApi();
52-
const repo = git?.repositories[0];
53-
const workspaceRoot = repo?.rootUri;
54-
55-
if (!workspaceRoot) {
56-
this.logService.warn('No workspace root found for file URIs');
49+
// Check if we have base and head commit SHAs
50+
if (!pullRequest.baseRefOid || !pullRequest.headRefOid) {
51+
this.logService.warn('PR missing base or head commit SHA, cannot create diff URIs');
5752
return undefined;
5853
}
5954

55+
const diffEntries: vscode.ChatResponseDiffEntry[] = [];
56+
6057
for (const file of files) {
61-
const fileUri = vscode.Uri.joinPath(workspaceRoot, file.filename);
62-
const originalUri = file.previous_filename
63-
? vscode.Uri.joinPath(workspaceRoot, file.previous_filename)
64-
: fileUri;
58+
// Always use remote URIs to ensure we show the exact PR content
59+
// Local files may be on different branches or have different changes
60+
this.logService.trace(`Creating remote URIs for ${file.filename}`);
61+
62+
const originalUri = toPRContentUri(
63+
file.previous_filename || file.filename,
64+
{
65+
owner: repoId.org,
66+
repo: repoId.repo,
67+
prNumber: pullRequest.number,
68+
commitSha: pullRequest.baseRefOid,
69+
isBase: true,
70+
previousFileName: file.previous_filename
71+
}
72+
);
73+
74+
const modifiedUri = toPRContentUri(
75+
file.filename,
76+
{
77+
owner: repoId.org,
78+
repo: repoId.repo,
79+
prNumber: pullRequest.number,
80+
commitSha: pullRequest.headRefOid,
81+
isBase: false
82+
}
83+
);
6584

66-
this.logService.trace(`DiffEntry -> original='${originalUri.fsPath}' modified='${fileUri.fsPath}' (+${file.additions} -${file.deletions})`);
85+
this.logService.trace(`DiffEntry -> original='${originalUri.toString()}' modified='${modifiedUri.toString()}' (+${file.additions} -${file.deletions})`);
6786
diffEntries.push({
6887
originalUri,
69-
modifiedUri: fileUri,
70-
goToFileUri: fileUri,
88+
modifiedUri,
89+
goToFileUri: modifiedUri,
7190
added: file.additions,
7291
removed: file.deletions,
7392
});
7493
}
7594

7695
const title = `Changes in Pull Request #${pullRequest.number}`;
77-
return new vscode.ChatResponseMultiDiffPart(diffEntries, title, true /* readOnly */);
96+
return new vscode.ChatResponseMultiDiffPart(diffEntries, title, false);
7897
} catch (error) {
7998
this.logService.error(`Failed to get file changes multi diff part: ${error}`);
8099
return undefined;

src/platform/github/common/githubAPI.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export interface PullRequestSearchItem {
2727
additions: number;
2828
deletions: number;
2929
fullDatabaseId: number;
30-
headRefOid: number;
30+
headRefOid: string;
31+
baseRefOid?: string;
3132
body: string;
3233
}
3334

@@ -184,6 +185,7 @@ export async function makeSearchGraphQLRequest(
184185
id
185186
fullDatabaseId
186187
headRefOid
188+
baseRefOid
187189
title
188190
state
189191
url
@@ -240,6 +242,7 @@ export async function getPullRequestFromGlobalId(
240242
id
241243
fullDatabaseId
242244
headRefOid
245+
baseRefOid
243246
title
244247
state
245248
url

src/platform/github/common/githubService.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { Endpoints } from "@octokit/types";
77
import { createServiceIdentifier } from '../../../util/common/services';
8+
import { decodeBase64 } from '../../../util/vs/base/common/buffer';
89
import { ICAPIClientService } from '../../endpoint/common/capiClient';
910
import { ILogService } from '../../log/common/logService';
1011
import { IFetcherService } from '../../networking/common/fetcherService';
@@ -261,6 +262,16 @@ export interface IOctoKitService {
261262
* @returns A promise that resolves to true if the PR was successfully closed
262263
*/
263264
closePullRequest(owner: string, repo: string, pullNumber: number): Promise<boolean>;
265+
266+
/**
267+
* Get file content from a specific commit.
268+
* @param owner The repository owner
269+
* @param repo The repository name
270+
* @param ref The commit SHA, branch name, or tag
271+
* @param path The file path within the repository
272+
* @returns The file content as a string
273+
*/
274+
getFileContent(owner: string, repo: string, ref: string, path: string): Promise<string>;
264275
}
265276

266277
/**
@@ -348,4 +359,14 @@ export class BaseOctoKitService {
348359
protected async closePullRequestWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise<boolean> {
349360
return closePullRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, owner, repo, pullNumber);
350361
}
362+
363+
protected async getFileContentWithToken(owner: string, repo: string, ref: string, path: string, token: string): Promise<string> {
364+
const response = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, `repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, 'GET', token, undefined);
365+
366+
if (response?.content && response.encoding === 'base64') {
367+
return decodeBase64(response.content.replace(/\n/g, '')).toString();
368+
} else {
369+
return '';
370+
}
371+
}
351372
}

src/platform/github/common/octoKitServiceImpl.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,12 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic
178178
}
179179
return this.closePullRequestWithToken(owner, repo, pullNumber, authToken);
180180
}
181+
182+
async getFileContent(owner: string, repo: string, ref: string, path: string): Promise<string> {
183+
const authToken = (await this._authService.getAnyGitHubSession())?.accessToken;
184+
if (!authToken) {
185+
throw new Error('No GitHub authentication available');
186+
}
187+
return this.getFileContentWithToken(owner, repo, ref, path, authToken);
188+
}
181189
}

0 commit comments

Comments
 (0)