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
1 change: 1 addition & 0 deletions resources/icons/codicons/diff-multiple.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
132 changes: 131 additions & 1 deletion src/github/pullRequestModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ import {
RestAccount,
restPaginate,
} from './utils';
import { Repository } from '../api/api';
import { Change, Repository } from '../api/api';
import { Status } from '../api/api1';
import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment';
import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk';
import { commands } from '../common/executeCommands';
Expand Down Expand Up @@ -1642,6 +1643,116 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args);
}

static async openReadonlyChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel): Promise<void> {
const headCommit = pullRequestModel.head?.sha;
if (!headCommit) {
throw new Error(`Pull request #${pullRequestModel.number} has no head commit.`);
}

let startCommit = pullRequestModel.item.merged
? pullRequestModel.base.sha
: await this.getLocalMergeBase(folderManager.repository, pullRequestModel.base.sha, headCommit);
let localChanges: Change[] | undefined;
if (startCommit && await this.commitsExistLocally(folderManager.repository, startCommit, headCommit)) {
localChanges = await folderManager.repository.diffBetween(startCommit, headCommit);
}

let remoteChanges: (InMemFileChange | SlimFileChange)[] | undefined;
if (!localChanges) {
const allChanges = await pullRequestModel.getAllFileChangesInfo();
remoteChanges = allChanges.changes;
startCommit = allChanges.mergeBase;
if (await this.commitsExistLocally(folderManager.repository, startCommit, headCommit)) {
localChanges = await folderManager.repository.diffBetween(startCommit, headCommit);
}
}

if (!startCommit) {
throw new Error(`Pull request #${pullRequestModel.number} has no base commit.`);
}

let args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][];
if (localChanges) {
args = localChanges.map(change => this.localChangeToMultiDiffEntry(change, startCommit, headCommit));
} else {
if (!remoteChanges) {
throw new Error(`Pull request #${pullRequestModel.number} has no file changes.`);
}
const remote = pullRequestModel.githubRepository.remote;
args = remoteChanges.map((change): [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined] => {
const rightUri = toGitHubCommitUri(change.fileName, { commit: headCommit, owner: remote.owner, repo: remote.repositoryName });
const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName ?? change.fileName : change.fileName;
const leftUri = toGitHubCommitUri(parentFileName, { commit: startCommit, owner: remote.owner, repo: remote.repositoryName });
if (change.status === GitChangeType.ADD) {
return [rightUri, undefined, rightUri];
} else if (change.status === GitChangeType.DELETE) {
return [rightUri, leftUri, undefined];
}
return [rightUri, leftUri, rightUri];
});
}

/* __GDPR__
"pr.viewChanges" : {
"source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
folderManager.telemetry.sendTelemetryEvent('pr.viewChanges', { source: localChanges ? 'git' : 'github' });
return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args);
}

private static async getLocalMergeBase(repository: Repository, baseCommit: string, headCommit: string): Promise<string | undefined> {
if (!await this.commitsExistLocally(repository, baseCommit, headCommit)) {
return;
}
try {
return await repository.getMergeBase(baseCommit, headCommit);
} catch (error) {
Logger.debug(`Using GitHub to determine the pull request merge base: ${formatError(error)}`, PullRequestModel.ID);
return;
}
}

private static async commitsExistLocally(repository: Repository, startCommit: string, endCommit: string): Promise<boolean> {
try {
await Promise.all([repository.getCommit(startCommit), repository.getCommit(endCommit)]);
return true;
} catch (error) {
Logger.debug(`Using GitHub content because the pull request commit range is not available locally: ${formatError(error)}`, PullRequestModel.ID);
return false;
}
}

private static localChangeToMultiDiffEntry(change: Change, startCommit: string, endCommit: string): [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined] {
const rightFileUri = change.renameUri ?? change.uri;
const leftFileUri = change.originalUri;
const rightUri = this.toGitCommitUri(rightFileUri, endCommit);
const leftUri = this.toGitCommitUri(leftFileUri, startCommit);

switch (change.status) {
case Status.INDEX_ADDED:
case Status.ADDED_BY_US:
case Status.ADDED_BY_THEM:
case Status.UNTRACKED:
case Status.INTENT_TO_ADD:
return [rightUri, undefined, rightUri];
case Status.INDEX_DELETED:
case Status.DELETED:
case Status.DELETED_BY_US:
case Status.DELETED_BY_THEM:
return [rightUri, leftUri, undefined];
default:
return [rightUri, leftUri, rightUri];
}
}

private static toGitCommitUri(fileUri: vscode.Uri, commit: string): vscode.Uri {
return fileUri.with({
scheme: Schemes.Git,
query: JSON.stringify({ path: fileUri.fsPath, ref: commit }),
});
}

static async openCommitChanges(extensionUri: vscode.Uri, githubRepository: GitHubRepository, commitSha: string) {
try {
const parentCommit = await githubRepository.getCommitParent(commitSha);
Expand Down Expand Up @@ -1806,6 +1917,25 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
return parsed;
}

async getAllFileChangesInfo(): Promise<{ changes: (InMemFileChange | SlimFileChange)[], mergeBase: string }> {
const githubRepository = this.githubRepository;
const { octokit, remote } = await githubRepository.ensure();
if (this.item.merged) {
const files = await restPaginate<typeof octokit.api.pulls.listFiles, IRawFileChange>(octokit.api.pulls.listFiles, {
repo: remote.repositoryName,
owner: remote.owner,
pull_number: this.number,
});
return { changes: await parseDiff(files, this.base.sha), mergeBase: this.base.sha };
}

if (!this.head) {
throw new Error(`Pull request #${this.number} has no head commit.`);
}
const { files, mergeBaseSha } = await compareCommits(remote, octokit, this.base, this.head, this.base.sha, this.number, PullRequestModel.ID);
return { changes: await parseDiff(files, mergeBaseSha), mergeBase: mergeBaseSha };
}

async getPatch(): Promise<string> {
const githubRepository = this.githubRepository;
const { octokit, remote } = await githubRepository.ensure();
Expand Down
13 changes: 13 additions & 0 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
currentUserReviewState: reviewState,
revertable: pullRequest.state === GithubItemStateEnum.Merged,
isCopilotOnMyBehalf: false,
isAgentSessionsWorkspace: vscode.workspace.isAgentSessionsWorkspace,
generateDescriptionTitle: this.getGenerateDescriptionTitle(),
attestationCommitsEnabled: isAttestationCommitsEnabled(),
closingIssues,
Expand Down Expand Up @@ -683,6 +684,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return this.openDiff(message);
case 'pr.open-changes':
return this.openChanges(message);
case 'pr.view-changes':
return this.viewChanges(message);
case 'pr.resolve-comment-thread':
return this.resolveCommentThread(message);
case 'pr.checkMergeability':
Expand Down Expand Up @@ -952,6 +955,16 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return PullRequestModel.openChanges(this._folderRepositoryManager, this._item, openToTheSide);
}

private async viewChanges(message: IRequestMessage<void>): Promise<void> {
const fileSystemProvider = getGitHubCommitFileSystemProvider();
if (!fileSystemProvider) {
throw new Error('GitHub commit file system provider is not initialized.');
}
fileSystemProvider.registerGitHubRepository(this._item.githubRepository);
await PullRequestModel.openReadonlyChanges(this._folderRepositoryManager, this._item);
await this._replyMessage(message, {});
}
Comment thread
alexr00 marked this conversation as resolved.

private resolveCommentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) {
// Serialize resolve/unresolve operations so that concurrent calls don't race.
// Each call fetches the full timeline after its mutation, and without serialization
Expand Down
1 change: 1 addition & 0 deletions src/github/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface Issue {

export interface PullRequest extends Issue {
isCopilotOnMyBehalf: boolean;
isAgentSessionsWorkspace: boolean;
isCurrentlyCheckedOut: boolean;
isRemoteBaseDeleted?: boolean;
base: string;
Expand Down
84 changes: 84 additions & 0 deletions src/test/github/pullRequestModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
*--------------------------------------------------------------------------------------------*/

import { default as assert } from 'assert';
import * as vscode from 'vscode';
import { MockCommandRegistry } from '../mocks/mockCommandRegistry';
import { Status } from '../../api/api1';
import { GitChangeType, SlimFileChange } from '../../common/file';
import { CredentialStore } from '../../github/credentials';
import { FolderRepositoryManager } from '../../github/folderRepositoryManager';
import { PullRequestModel } from '../../github/pullRequestModel';
import { GithubItemStateEnum } from '../../github/interface';
import { Protocol } from '../../common/protocol';
Expand Down Expand Up @@ -98,6 +102,86 @@ describe('PullRequestModel', function () {
assert.strictEqual(open.state, GithubItemStateEnum.Merged);
});

describe('openReadonlyChanges', function () {
const baseCommit = '1111111111111111111111111111111111111111';
const mergeBase = '2222222222222222222222222222222222222222';
const headCommit = '3333333333333333333333333333333333333333';

function createPullRequestModel(): PullRequestModel {
const pr = new PullRequestBuilder()
.base(base => base.sha(baseCommit))
.head(head => head.sha(headCommit))
.build();
return new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo));
}

it('uses the git filesystem when the commit range is available locally', async function () {
const model = createPullRequestModel();
const oldUri = vscode.Uri.file('C:\\users\\test\\repo\\old.ts');
const newUri = vscode.Uri.file('C:\\users\\test\\repo\\new.ts');
const getCommit = sinon.stub().resolves({ hash: '', message: '', parents: [] });
const getMergeBase = sinon.stub().resolves(mergeBase);
const diffBetween = sinon.stub().resolves([{
uri: newUri,
originalUri: oldUri,
renameUri: newUri,
status: Status.INDEX_RENAMED,
}]);
const executeCommand = sinon.stub(vscode.commands, 'executeCommand').resolves();
const folderManager = {
repository: { getCommit, getMergeBase, diffBetween },
telemetry,
} as unknown as FolderRepositoryManager;

await PullRequestModel.openReadonlyChanges(folderManager, model);

assert(getMergeBase.calledOnceWithExactly(baseCommit, headCommit));
assert(diffBetween.calledOnceWithExactly(mergeBase, headCommit));
const [command, , entries] = executeCommand.firstCall.args;
assert.strictEqual(command, 'vscode.changes');
assert.strictEqual(entries.length, 1);
const [resourceUri, originalUri, modifiedUri] = entries[0];
assert.strictEqual(resourceUri.scheme, 'git');
assert.strictEqual(originalUri.scheme, 'git');
assert.strictEqual(modifiedUri.scheme, 'git');
assert.deepStrictEqual(JSON.parse(originalUri.query), { path: oldUri.fsPath, ref: mergeBase });
assert.deepStrictEqual(JSON.parse(modifiedUri.query), { path: newUri.fsPath, ref: headCommit });
});

it('uses GitHub when the commit range is not available locally', async function () {
const model = createPullRequestModel();
const getCommit = sinon.stub().rejects(new Error('Unknown commit'));
const getAllFileChangesInfo = sinon.stub(model, 'getAllFileChangesInfo').resolves({
changes: [new SlimFileChange(mergeBase, '', GitChangeType.RENAME, 'new.ts', 'old.ts')],
mergeBase,
});
const executeCommand = sinon.stub(vscode.commands, 'executeCommand').resolves();
const folderManager = {
repository: { getCommit },
telemetry,
} as unknown as FolderRepositoryManager;

await PullRequestModel.openReadonlyChanges(folderManager, model);

assert(getAllFileChangesInfo.calledOnce);
const [, , entries] = executeCommand.firstCall.args;
const [resourceUri, originalUri, modifiedUri] = entries[0];
assert.strictEqual(resourceUri.scheme, 'githubcommit');
assert.strictEqual(originalUri.scheme, 'githubcommit');
assert.strictEqual(modifiedUri.scheme, 'githubcommit');
assert.deepStrictEqual(JSON.parse(originalUri.query), {
commit: mergeBase,
owner: repo.remote.owner,
repo: repo.remote.repositoryName,
});
assert.deepStrictEqual(JSON.parse(modifiedUri.query), {
commit: headCommit,
owner: repo.remote.owner,
repo: repo.remote.repositoryName,
});
});
});

describe('reviewThreadCache', function () {
function page(id: string, endCursor: string | null) {
return {
Expand Down
2 changes: 2 additions & 0 deletions webviews/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export class PRContext {

public openChanges = (openToTheSide?: boolean) => this.postMessage({ command: 'pr.open-changes', args: { openToTheSide } });

public viewChanges = () => this.postMessage({ command: 'pr.view-changes' });

public copyPrLink = () => this.postMessage({ command: 'pr.copy-prlink' });

public copyVscodeDevLink = () => this.postMessage({ command: 'pr.copy-vscodedevlink' });
Expand Down
19 changes: 17 additions & 2 deletions webviews/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import React, { useContext, useState } from 'react';
import { ContextDropdown } from './contextDropdown';
import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, editIcon, gitMergeIcon, gitPullRequestClosedIcon, gitPullRequestDraftIcon, gitPullRequestIcon, issuescon, loadingIcon, passIcon } from './icon';
import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, diffMultipleIcon, editIcon, gitMergeIcon, gitPullRequestClosedIcon, gitPullRequestDraftIcon, gitPullRequestIcon, issuescon, loadingIcon, passIcon } from './icon';
import { AuthorLink, Avatar } from './user';
import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../../src/common/copilot';
import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent';
Expand All @@ -28,6 +28,7 @@ export function Header({
isCurrentlyCheckedOut,
isDraft,
isIssue,
isAgentSessionsWorkspace,
doneCheckoutBranch,
events,
owner,
Expand All @@ -50,6 +51,8 @@ export function Header({
setEditMode={setEditMode}
setCurrentTitle={setCurrentTitle}
canEdit={canEdit}
isIssue={isIssue}
isAgentSessionsWorkspace={isAgentSessionsWorkspace}
owner={owner}
repo={repo}
/>
Expand Down Expand Up @@ -79,11 +82,13 @@ interface TitleProps {
setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
setCurrentTitle: React.Dispatch<React.SetStateAction<string>>;
canEdit: boolean;
isIssue: boolean;
isAgentSessionsWorkspace: boolean;
owner: string;
repo: string;
}

function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, owner, repo }: TitleProps): JSX.Element {
function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, isIssue, isAgentSessionsWorkspace, owner, repo }: TitleProps): JSX.Element {
const { setTitle, copyPrLink, openOnGitHub } = useContext(PullRequestContext);

const titleForm = (
Expand Down Expand Up @@ -146,13 +151,23 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr
<button title="Copy Link" onClick={copyPrLink} className="icon-button" aria-label="Copy Pull Request Link">
{copyIcon}
</button>
{!isIssue && isAgentSessionsWorkspace ? <ViewChangesButton /> : null}
</div>
);

const editableTitle = inEditMode ? titleForm : displayTitle;
return editableTitle;
}

export function ViewChangesButton(): JSX.Element {
const { viewChanges } = useContext(PullRequestContext);
return (
<button title="View Changes" onClick={viewChanges} className="icon-button" aria-label="View Pull Request Changes">
{diffMultipleIcon}
</button>
);
}

interface ButtonGroupProps {
isCurrentlyCheckedOut: boolean;
isIssue: boolean;
Expand Down
1 change: 1 addition & 0 deletions webviews/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const cloudUploadIcon = <Icon src={require('../../resources/icons/codicon
export const commentIcon = <Icon src={require('../../resources/icons/codicons/comment.svg')} />;
export const copilotIcon = <Icon src={require('../../resources/icons/codicons/copilot.svg')} />;
export const copyIcon = <Icon src={require('../../resources/icons/codicons/copy.svg')} />;
export const diffMultipleIcon = <Icon src={require('../../resources/icons/codicons/diff-multiple.svg')} />;
export const editIcon = <Icon src={require('../../resources/icons/codicons/edit.svg')} />;
export const errorIcon = <Icon src={require('../../resources/icons/codicons/error.svg')} />;
export const feedbackIcon = <Icon src={require('../../resources/icons/codicons/feedback.svg')} />;
Expand Down
3 changes: 2 additions & 1 deletion webviews/components/stickyHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import * as React from 'react';
import { getStatus } from './header';
import { getStatus, ViewChangesButton } from './header';
import { copyIcon } from './icon';
import { PullRequest } from '../../src/github/views';
import PullRequestContext from '../common/context';
Expand Down Expand Up @@ -74,6 +74,7 @@ export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolea
<button title="Copy Link" onClick={copyPrLink} className="icon-button sticky-header-copy" aria-label="Copy Pull Request Link">
{copyIcon}
</button>
{!pr.isIssue && pr.isAgentSessionsWorkspace ? <ViewChangesButton /> : null}
</div>
</div>
);
Expand Down
Loading