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
35 changes: 35 additions & 0 deletions src/github/githubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,41 @@ export class GitHubRepository extends Disposable {
}
}

async unstackAll(pullRequestNumber: number, expectedPullRequests: readonly number[]): Promise<number[]> {
const { octokit, remote } = await this.ensure();
const params = {
owner: remote.owner,
repo: remote.repositoryName,
headers: { 'X-GitHub-Api-Version': '2026-03-10' },
};
const { data: stacks } = await octokit.call(() => octokit.api.request('GET /repos/{owner}/{repo}/stacks', {
...params,
pull_request: pullRequestNumber,
per_page: 1,
}));
if (!Array.isArray(stacks) || stacks.length !== 1 || !isObject(stacks[0])
|| typeof stacks[0].number !== 'number' || !Array.isArray(stacks[0].pull_requests)
|| !stacks[0].pull_requests.some((pr: unknown) => isObject(pr) && pr.number === pullRequestNumber)) {
throw new Error(`Could not find the stack containing pull request #${pullRequestNumber}.`);
}
if (stacks[0].pull_requests.length !== expectedPullRequests.length
|| stacks[0].pull_requests.some((pr: unknown, index: number) => !isObject(pr) || pr.number !== expectedPullRequests[index])) {
throw new Error('The pull request stack has changed. Refresh the view and try again.');
}
const result = await octokit.call(() => octokit.api.request('POST /repos/{owner}/{repo}/stacks/{stack_number}/unstack', {
...params,
stack_number: stacks[0].number,
}));
if (result.status === 204) {
return [];
}
if (result.status !== 200 || !isObject(result.data) || !Array.isArray(result.data.pull_requests)
|| !result.data.pull_requests.every((pr: unknown) => isObject(pr) && typeof pr.number === 'number')) {
throw new Error('GitHub returned an invalid result when unstacking pull requests.');
}
return result.data.pull_requests.map((pr: { number: number }) => pr.number);
}

async canGetProjectsNow(): Promise<boolean> {
let { schema } = await this.ensure();
if (schema.GetRepoProjects && schema.GetOrgProjects) {
Expand Down
65 changes: 63 additions & 2 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel';
import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon';
import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks';
import { getIssueOrURLExpression, parseIssueExpressionOutput, parseReviewers, processDiffLinks, processPermalinks } from './utils';
import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, UnresolvedIdentity } from './views';
import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, UnresolvedIdentity, UnstackAllResult } from './views';
import { debounce } from '../common/async';
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
import { COPILOT_REVIEWER, COPILOT_REVIEWER_ACCOUNT, COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot';
Expand Down Expand Up @@ -181,6 +181,13 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return super.findPanel(owner, repo, number) as PullRequestOverviewPanel | undefined;
}

public static async refreshStackPanels(owner: string, repo: string, numbers: readonly number[]): Promise<void> {
const panels = numbers
.map(number => this.findPanel(owner, repo, number))
.filter((panel): panel is PullRequestOverviewPanel => !!panel);
await Promise.all(panels.map(panel => panel.refreshPanel()));
}

/**
* Register the webview context-menu commands once globally,
* rather than per panel instance. Each command receives the
Expand Down Expand Up @@ -266,7 +273,19 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode

if (this._item) {
this._prListeners.push(this._item.onDidChange(e => {
if ((e.state || e.comments) && !this._refreshing && !this._updateItemPromise) {
if (e.draft) {
const item = this._item;
void this.refreshPanel();
void item.getStack().then(stack => {
if (stack) {
return PullRequestOverviewPanel.refreshStackPanels(item.remote.owner, item.remote.repositoryName,
stack.pullRequests.filter(entry => entry.number !== item.number).map(entry => entry.number));
}
}).catch(error => {
Logger.error(`Failed to refresh pull request stack after draft change: ${formatError(error)}`, PullRequestOverviewPanel.ID);
void vscode.window.showErrorMessage(vscode.l10n.t('Unable to refresh pull request stack: {0}', formatError(error)));
});
} else if ((e.state || e.comments) && !this._refreshing && !this._updateItemPromise) {
this.refreshPanel();
}
}));
Expand Down Expand Up @@ -750,6 +769,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return this.mergePullRequest(message);
case 'pr.merge-stack':
return PullRequestReviewCommon.mergeStack(this.getReviewContext(), message);
case 'pr.unstack-all':
return this.unstackAll(message);
case 'pr.change-email':
return this.changeEmail(message);
case 'pr.deleteBranch':
Expand Down Expand Up @@ -1094,6 +1115,46 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
);
}

private async unstackAll(message: IRequestMessage<undefined>): Promise<void> {
try {
const access = await this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(this._item);
if (!access.hasWritePermission) {
throw new Error(vscode.l10n.t('You do not have permission to unstack these pull requests.'));
}
const stack = await this._item.getStack();
if (!stack || !stack.pullRequests.some(pr => pr.state !== GithubItemStateEnum.Merged)) {
throw new Error(vscode.l10n.t('No unmerged pull requests are available to unstack.'));
}
const expectedPullRequests = stack.pullRequests.map(pr => pr.number);
const action = vscode.l10n.t('Unstack all');
const answer = await vscode.window.showWarningMessage(
vscode.l10n.t('Unstack all eligible pull requests?'),
{
modal: true,
detail: vscode.l10n.t('Eligible open, draft, and closed pull requests will be removed from this stack. Their base branches will not change. Merged, queued, and currently merging pull requests will remain in the stack.'),
},
action,
);
if (answer !== action) {
await this._replyMessage(message, { cancelled: true } satisfies UnstackAllResult);
return;
}
const remainingPullRequests = await this._item.githubRepository.unstackAll(this._item.number, expectedPullRequests);
await this._replyMessage(message, { cancelled: false, remainingPullRequests } satisfies UnstackAllResult);
await PullRequestOverviewPanel.refreshStackPanels(this._identity.owner, this._identity.repo,
stack.pullRequests.map(pr => pr.number));
if (remainingPullRequests.length === stack.size) {
void vscode.window.showInformationMessage(vscode.l10n.t('No pull requests were unstacked. Merged, queued, or currently merging pull requests remain in the stack.'));
} else {
void vscode.window.showInformationMessage(vscode.l10n.t('Eligible pull requests unstacked. {0} merged, queued, or currently merging pull requests remain in the stack.', remainingPullRequests.length));
}
Comment on lines +1146 to +1150
} catch (error) {
Logger.error(`Failed to unstack pull requests: ${formatError(error)}`, PullRequestOverviewPanel.ID);
void vscode.window.showErrorMessage(vscode.l10n.t('Unable to unstack pull requests: {0}', formatError(error)));
await this._throwError(message, formatError(error));
}
}

private async mergePullRequest(
message: IRequestMessage<MergeArguments>,
): Promise<void> {
Expand Down
5 changes: 5 additions & 0 deletions src/github/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export interface StackMergeResult {
state?: GithubItemStateEnum;
}

export interface UnstackAllResult {
cancelled: boolean;
remainingPullRequests?: number[];
}

export interface DeleteReviewResult {
deletedReviewId: number;
deletedReviewComments: IComment[];
Expand Down
68 changes: 68 additions & 0 deletions src/test/github/pullRequestModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,74 @@ describe('PullRequestModel', function () {
});
});

describe('unstackAll', function () {
const headers = { 'X-GitHub-Api-Version': '2026-03-10' };
const params = { owner: 'github', repo: 'test', headers };
const listRoute = 'GET /repos/{owner}/{repo}/stacks';
const unstackRoute = 'POST /repos/{owner}/{repo}/stacks/{stack_number}/unstack';
const listArgs = [listRoute, { ...params, pull_request: 795, per_page: 1 }];
const unstackArgs = [unstackRoute, { ...params, stack_number: 12 }];

it('dissolves a stack when GitHub returns 204', async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 12, pull_requests: [{ number: 794 }, { number: 795 }],
}]);
repo.queryProvider.expectOctokitRequest(['request'], unstackArgs, undefined, 204);

assert.deepStrictEqual(await repo.unstackAll(795, [794, 795]), []);
});

it('reports locked PRs remaining after unstacking', async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 12, pull_requests: [{ number: 794 }, { number: 795 }],
}]);
repo.queryProvider.expectOctokitRequest(['request'], unstackArgs, {
number: 12, pull_requests: [{ number: 794 }],
}, 200);

assert.deepStrictEqual(await repo.unstackAll(795, [794, 795]), [794]);
});

it('does not unstack a different or missing stack', async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 12, pull_requests: [{ number: 794 }],
}]);
await assert.rejects(repo.unstackAll(795, [794, 795]), /Could not find the stack/);
});

for (const { description, numbers } of [
{ description: 'a different stack', numbers: [796, 795] },
{ description: 'an added PR', numbers: [794, 795, 796] },
{ description: 'a removed PR', numbers: [795] },
{ description: 'reordered PRs', numbers: [795, 794] },
]) {
it(`rejects ${description} after confirmation before unstacking`, async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 13, pull_requests: numbers.map(number => ({ number })),
}]);

await assert.rejects(repo.unstackAll(795, [794, 795]), /stack has changed/);
});
}

it('reports an invalid successful response rather than assuming the stack dissolved', async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 12, pull_requests: [{ number: 795 }],
}]);
repo.queryProvider.expectOctokitRequest(['request'], unstackArgs, { pull_requests: null }, 200);
await assert.rejects(repo.unstackAll(795, [795]), /invalid result/);
});

it('surfaces an unstack failure rather than reporting success', async function () {
repo.queryProvider.expectOctokitRequest(['request'], listArgs, [{
number: 12, pull_requests: [{ number: 795 }],
}]);
repo.queryProvider.expectOctokitError(['request'], unstackArgs, new Error('Stack is locked'));

await assert.rejects(repo.unstackAll(795, [795]), /Stack is locked/);
});
});

describe('openReadonlyChanges', function () {
const baseCommit = '1111111111111111111111111111111111111111';
const mergeBase = '2222222222222222222222222222222222222222';
Expand Down
Loading
Loading