Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 src/@types/vscode.proposed.chatParticipantAdditions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ declare module 'vscode' {
isComplete?: boolean;
toolSpecificData?: ChatTerminalToolInvocationData;
fromSubAgent?: boolean;
presentation?: 'hidden' | 'hiddenAfterComplete' | undefined;

constructor(toolName: string, toolCallId: string, isError?: boolean);
}
Expand Down
42 changes: 4 additions & 38 deletions src/github/createPRViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import { IAccount, ILabel, IMilestone, IProject, isITeam, ITeam, MergeMethod, Re
import { BaseBranchMetadata, PullRequestGitHelper } from './pullRequestGitHelper';
import { PullRequestModel } from './pullRequestModel';
import { getDefaultMergeMethod } from './pullRequestOverview';
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks';
import { branchPicks, getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks';
import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils';
import { DisplayLabel, PreReviewState } from './views';
import { RemoteInfo } from '../../common/types';
import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views';
import type { Branch, Ref } from '../api/api';
import type { Branch } from '../api/api';
import { GitHubServerType } from '../common/authentication';
import { emojify, ensureEmojis } from '../common/emoji';
import { commands, contexts } from '../common/executeCommands';
Expand Down Expand Up @@ -812,40 +812,6 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv
});
}

private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> {
let branches: (string | Ref)[];
if (isBase) {
// For the base, we only want to show branches from GitHub.
branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName);
} else {
// For the compare, we only want to show local branches.
branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name);
}
// TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list.
const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => {
const branchName = typeof branch === 'string' ? branch : branch.name!;
const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = {
iconPath: new vscode.ThemeIcon('git-branch'),
label: branchName,
remote: {
owner: githubRepository.remote.owner,
repositoryName: githubRepository.remote.repositoryName
},
branch: branchName
};
return pick;
});
branchPicks.unshift({
kind: vscode.QuickPickItemKind.Separator,
label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`
});
branchPicks.unshift({
iconPath: new vscode.ThemeIcon('repo'),
label: changeRepoMessage
});
return branchPicks;
}

private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) {
const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]);

Expand Down Expand Up @@ -922,7 +888,7 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv
quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder;
quickPick.show();
quickPick.busy = true;
quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : await this.remotePicks(isBase);
quickPick.items = githubRepository ? await branchPicks(githubRepository, this._folderRepositoryManager, chooseDifferentRemote, isBase) : await this.remotePicks(isBase);
const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined;
quickPick.activeItems = activeItem ? [activeItem] : [];
quickPick.busy = false;
Expand All @@ -941,7 +907,7 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv
const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo };
quickPick.busy = true;
githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!;
quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase);
quickPick.items = await branchPicks(githubRepository, this._folderRepositoryManager, chooseDifferentRemote, isBase);
quickPick.placeholder = branchPlaceholder;
quickPick.busy = false;
} else if (selectedPick.branch && selectedPick.remote) {
Expand Down
1 change: 1 addition & 0 deletions src/github/issueModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface IssueChangeEvent {

draft?: true;
reviewers?: true;
base?: true;
}

export class IssueModel<TItem extends Issue = Issue> extends Disposable {
Expand Down
41 changes: 41 additions & 0 deletions src/github/pullRequestModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
SubmitReviewResponse,
TimelineEventsResponse,
UnresolveReviewThreadResponse,
UpdateIssueResponse,
} from './graphql';
import {
AccountType,
Expand Down Expand Up @@ -1199,6 +1200,46 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
return true;
}

/**
* Update the base branch of the pull request.
* @param newBaseBranch The new base branch name
*/
async updateBaseBranch(newBaseBranch: string): Promise<void> {
Logger.debug(`Updating base branch to ${newBaseBranch} - enter`, PullRequestModel.ID);
try {
const { mutate, schema } = await this.githubRepository.ensure();

const { data } = await mutate<UpdateIssueResponse>({
mutation: schema.UpdatePullRequest,
variables: {
input: {
pullRequestId: this.graphNodeId,
baseRefName: newBaseBranch,
},
},
});

if (data?.updateIssue?.issue) {
// Update the local base branch reference by creating a new GitHubRef instance
const cloneUrl = this.base.repositoryCloneUrl.toString() || '';
this.base = new GitHubRef(
newBaseBranch,
`${this.base.owner}:${newBaseBranch}`,
this.base.sha,
cloneUrl,
this.base.owner,
this.base.name,
this.base.isInOrganization
);
this._onDidChange.fire({ base: true });
}
Logger.debug(`Updating base branch to ${newBaseBranch} - done`, PullRequestModel.ID);
} catch (e) {
Logger.error(`Updating base branch to ${newBaseBranch} failed: ${e}`, PullRequestModel.ID);
throw e;
}
}

/**
* Get existing requests to review.
*/
Expand Down
48 changes: 46 additions & 2 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
import { IssueOverviewPanel } from './issueOverview';
import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel';
import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon';
import { pickEmail, reviewersQuickPick } from './quickPicks';
import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks';
import { parseReviewers } from './utils';
import { CancelCodingAgentReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType } from './views';
import { CancelCodingAgentReply, ChangeBaseReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType } from './views';
import { IComment } from '../common/comment';
import { COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot';
import { commands, contexts } from '../common/executeCommands';
Expand Down Expand Up @@ -425,6 +425,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return this.openCommitChanges(message);
case 'pr.delete-review':
return this.deleteReview(message);
case 'pr.change-base-branch':
return this.changeBaseBranch(message);
}
}

Expand Down Expand Up @@ -805,6 +807,48 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
}
}

private async changeBaseBranch(message: IRequestMessage<void>): Promise<void> {
const quickPick = vscode.window.createQuickPick<vscode.QuickPickItem & { branch?: string }>();

try {
quickPick.busy = true;
quickPick.canSelectMany = false;
quickPick.placeholder = vscode.l10n.t('Select a new base branch');
quickPick.show();

quickPick.items = await branchPicks(this._item.githubRepository, this._folderRepositoryManager, undefined, true);

quickPick.busy = false;
const acceptPromise = asPromise<void>(quickPick.onDidAccept).then(() => {
return quickPick.selectedItems[0]?.branch;
});
const hidePromise = asPromise<void>(quickPick.onDidHide);
const selectedBranch = await Promise.race<string | void>([acceptPromise, hidePromise]);
quickPick.busy = true;
quickPick.enabled = false;

if (selectedBranch) {
try {
await this._item.updateBaseBranch(selectedBranch);
const reply: ChangeBaseReply = {
base: selectedBranch
};
await this._replyMessage(message, reply);
} catch (e) {
Logger.error(formatError(e), PullRequestOverviewPanel.ID);
vscode.window.showErrorMessage(vscode.l10n.t('Changing base branch failed. {0}', formatError(e)));
this._throwError(message, `${formatError(e)}`);
}
}
} catch (e) {
Logger.error(formatError(e), PullRequestOverviewPanel.ID);
vscode.window.showErrorMessage(formatError(e));
} finally {
quickPick.hide();
quickPick.dispose();
}
}

override dispose() {
super.dispose();
disposeAll(this._prListeners);
Expand Down
38 changes: 38 additions & 0 deletions src/github/quickPicks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository';
import { AccountType, IAccount, ILabel, IMilestone, IProject, isISuggestedReviewer, isITeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface';
import { IssueModel } from './issueModel';
import { DisplayLabel } from './views';
import { RemoteInfo } from '../../common/types';
import { Ref } from '../api/api';
import { COPILOT_ACCOUNTS } from '../common/comment';
import { COPILOT_REVIEWER, COPILOT_REVIEWER_ID, COPILOT_SWE_AGENT } from '../common/copilot';
import { emojify, ensureEmojis } from '../common/emoji';
Expand Down Expand Up @@ -479,4 +481,40 @@ export async function pickEmail(githubRepository: GitHubRepository, current: str

const result = await vscode.window.showQuickPick(getEmails(), { canPickMany: false, title: vscode.l10n.t('Choose an email') });
return result ? result.label : undefined;
}

export async function branchPicks(githubRepository: GitHubRepository, folderRepoManager: FolderRepositoryManager, changeRepoMessage: string | undefined, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> {
let branches: (string | Ref)[];
if (isBase) {
// For the base, we only want to show branches from GitHub.
branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName);
} else {
// For the compare, we only want to show local branches.
branches = (await folderRepoManager.repository.getBranches({ remote: false })).filter(branch => branch.name);
}
// TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list.
const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => {
const branchName = typeof branch === 'string' ? branch : branch.name!;
const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = {
iconPath: new vscode.ThemeIcon('git-branch'),
label: branchName,
remote: {
owner: githubRepository.remote.owner,
repositoryName: githubRepository.remote.repositoryName
},
branch: branchName
};
return pick;
});
branchPicks.unshift({
kind: vscode.QuickPickItemKind.Separator,
label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`
});
if (changeRepoMessage) {
branchPicks.unshift({
iconPath: new vscode.ThemeIcon('repo'),
label: changeRepoMessage
});
}
return branchPicks;
}
4 changes: 4 additions & 0 deletions src/github/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,8 @@ export interface OverviewContext {
export interface CodingAgentContext extends SessionLinkInfo {
'preventDefaultContextMenuItems': true;
[key: string]: boolean | string | number | undefined;
}

export interface ChangeBaseReply {
base: string;
}
10 changes: 10 additions & 0 deletions src/test/github/pullRequestModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,14 @@ describe('PullRequestModel', function () {
assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['removed'].length, 0);
});
});

describe('updateBaseBranch', function () {
it('should have updateBaseBranch method', function () {
const pr = new PullRequestBuilder().build();
const model = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo));

// Verify the method exists
assert.strictEqual(typeof model.updateBaseBranch, 'function');
});
});
});
8 changes: 7 additions & 1 deletion webviews/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { CloseResult, OpenCommitChangesArgs } from '../../common/views';
import { IComment } from '../../src/common/comment';
import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent';
import { IProjectItem, MergeMethod, ReadyForReview } from '../../src/github/interface';
import { CancelCodingAgentReply, ChangeAssigneesReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply } from '../../src/github/views';
import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply } from '../../src/github/views';

export class PRContext {
constructor(
Expand Down Expand Up @@ -92,6 +92,12 @@ export class PRContext {
public readyForReviewAndMerge = (args: { mergeMethod: MergeMethod }): Promise<ReadyForReview> => this.postMessage({ command: 'pr.readyForReviewAndMerge', args });

public addReviewers = () => this.postMessage({ command: 'pr.change-reviewers' });
public changeBaseBranch = async () => {
const result: ChangeBaseReply = await this.postMessage({ command: 'pr.change-base-branch' });
if (result?.base) {
this.updatePR({ base: result.base });
}
};
public changeProjects = (): Promise<ProjectItemsReply> => this.postMessage({ command: 'pr.change-projects' });
public removeProject = (project: IProjectItem) => this.postMessage({ command: 'pr.remove-project', args: project });
public addMilestone = () => this.postMessage({ command: 'pr.add-milestone' });
Expand Down
14 changes: 11 additions & 3 deletions webviews/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function Header({
owner={owner}
repo={repo}
/>
<Subtitle state={state} stateReason={stateReason} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} codingAgentEvent={codingAgentEvent} />
<Subtitle state={state} stateReason={stateReason} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} codingAgentEvent={codingAgentEvent} canEdit={canEdit} />
<div className="header-actions">
<ButtonGroup
isCurrentlyCheckedOut={isCurrentlyCheckedOut}
Expand Down Expand Up @@ -248,9 +248,11 @@ interface SubtitleProps {
base: string;
head: string;
codingAgentEvent: TimelineEvent | undefined;
canEdit: boolean;
}

function Subtitle({ state, stateReason, isDraft, isIssue, author, base, head, codingAgentEvent }: SubtitleProps): JSX.Element {
function Subtitle({ state, stateReason, isDraft, isIssue, author, base, head, codingAgentEvent, canEdit }: SubtitleProps): JSX.Element {
const { changeBaseBranch } = useContext(PullRequestContext);
const { text, color, icon } = getStatus(state, !!isDraft, isIssue, stateReason);
const copilotStatus = copilotEventToStatus(codingAgentEvent);
let copilotStatusIcon: JSX.Element | undefined;
Expand All @@ -273,7 +275,13 @@ function Subtitle({ state, stateReason, isDraft, isIssue, author, base, head, co
<div className="merge-branches">
<AuthorLink for={author} /> {!isIssue ? (<>
{getActionText(state)} into{' '}
<code className="branch-tag">{base}</code> from <code className="branch-tag">{head}</code>
<code className="branch-tag">{base}</code>
{canEdit && state === GithubItemStateEnum.Open ? (
<button title="Change base branch" onClick={changeBaseBranch} className="icon-button">
{editIcon}
</button>
) : null}
{' '}from <code className="branch-tag">{head}</code>
</>) : null}
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions webviews/editorWebview/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,10 @@ body button .icon {
border-radius: 4px;
}

.merge-branches .icon-button {
margin-top: 4px;
}

.subtitle .created-at {
margin-left: auto;
white-space: nowrap;
Expand Down