diff --git a/.husky/pre-commit b/.husky/pre-commit index 1e24795ff3..2d5f979443 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -10,7 +10,7 @@ lint_pid='' # Stream a NUL-delimited list directly to xargs -0 so paths with spaces/newlines # are handled safely (command substitution would strip NUL bytes). if git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' | grep -q .; then - git diff --cached --name-only --diff-filter=ACMR -z -- '*.ts' '*.tsx' | xargs -0 node ./node_modules/eslint/bin/eslint.js --fix --cache & + git diff --cached --name-only --diff-filter=ACMR -z -- '*.ts' '*.tsx' | xargs -0 node ./node_modules/eslint/bin/eslint.js --fix --cache --no-warn-ignored & lint_pid=$! fi diff --git a/resources/icons/codicons/layers.svg b/resources/icons/codicons/layers.svg new file mode 100644 index 0000000000..011f0c1ec6 --- /dev/null +++ b/resources/icons/codicons/layers.svg @@ -0,0 +1 @@ + diff --git a/src/common/utils.ts b/src/common/utils.ts index fd2e09b67f..adaa0727ec 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -98,6 +98,10 @@ export function onceEvent(event: Event): Event { }; } +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + function isWindowsPath(path: string): boolean { return /^[a-zA-Z]:\\/.test(path); } diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index 27d5282078..9ca57f20aa 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -89,6 +89,8 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W return this.createComment(message); case 'pr.merge': return this.mergePullRequest(message); + case 'pr.merge-stack': + return PullRequestReviewCommon.mergeStack(this.getReviewContext(), message); case 'pr.open-create': return this.create(); case 'pr.deleteBranch': @@ -300,6 +302,9 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W mergeMethodsAvailability, defaultMergeMethod, mergeQueueMethod, + stack: undefined, + stackLoaded: false, + stackLoadError: false, repositoryDefaultBranch: defaultBranch, doneCheckoutBranch, isIssue: false, @@ -318,6 +323,27 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W command: 'pr.initialize', pullrequest: context, }); + void pullRequest.getStack().then(async stack => { + if (!this._item.equals(pullRequest)) { + return; + } + const stackQueueMethod = stack ? await this._folderRepositoryManager.mergeQueueMethodForBranch(stack.base, pullRequest.remote.owner, pullRequest.remote.repositoryName) : undefined; + if (this._item.equals(pullRequest)) { + this._postMessage({ + command: 'pr.update', + pullrequest: { + stack, + stackLoaded: true, + ...(stack ? { mergeQueueMethod: stackQueueMethod } : {}), + } satisfies Partial, + }); + } + }).catch(error => { + Logger.error(`Failed to load active pull request stack: ${formatError(error)}`, PullRequestViewProvider.name); + if (this._item.equals(pullRequest)) { + this._postMessage({ command: 'pr.update', pullrequest: { stackLoadError: true } satisfies Partial }); + } + }); } catch (e) { vscode.window.showErrorMessage(`Error updating active pull request view: ${formatError(e)}`); diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 9428a7205b..cafd947a77 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -340,7 +340,7 @@ export class GitHubRepository extends Disposable { } } - query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables: OperationVariables }): Promise> => { + query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables: OperationVariables }, allowLimitedFallback: boolean = true): Promise> => { const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; @@ -367,7 +367,7 @@ export class GitHubRepository extends Disposable { return this.query(query, ignoreSamlErrors); } - if (gqlErrors && gqlErrors.length && (gqlErrors.some(error => error.extensions?.code === 'undefinedField')) && !this._areQueriesLimited) { + if (allowLimitedFallback && gqlErrors && gqlErrors.length && (gqlErrors.some(error => error.extensions?.code === 'undefinedField')) && !this._areQueriesLimited) { // We're running against a GitHub server that doesn't support the query we're trying to run. // Switch to the limited schema and try again. this._areQueriesLimited = true; diff --git a/src/github/graphql.ts b/src/github/graphql.ts index f0bc75a5ea..36aaa10682 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ForkDetails } from './githubRepository'; +import { GithubItemStateEnum } from './interface'; import { DiffSide, SubjectType, ViewedState } from '../common/comment'; interface PageInfo { @@ -815,6 +816,34 @@ export interface PullRequestResponse { rateLimit: RateLimit; } +export interface PullRequestStackResponse { + repository: { + pullRequest: { + stackEntry: { position: number } | null; + stack: { + size: number; + baseRefName: string; + entries: { + nodes: { + position: number; + pullRequest: { + number: number; + title: string; + url: string; + state: GithubItemStateEnum; + isDraft: boolean; + headRefName: string; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + }; + }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + } | null; + } | null; + } | null; +} + export interface IssueResponse { repository: { issue: PullRequest; diff --git a/src/github/interface.ts b/src/github/interface.ts index 30fa68149d..6ae32f87a5 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -23,6 +23,22 @@ export enum GithubItemStateEnum { Closed = 'CLOSED', } +export interface PullRequestStack { + position: number; + size: number; + base: string; + pullRequests: { + position: number; + number: number; + title: string; + url: string; + head: string; + state: GithubItemStateEnum; + isDraft: boolean; + mergeable: PullRequestMergeability; + }[]; +} + export enum PullRequestMergeability { Mergeable, NotMergeable, diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts index a6c9295ab6..13f45d4dfc 100644 --- a/src/github/loggingOctokit.ts +++ b/src/github/loggingOctokit.ts @@ -14,6 +14,7 @@ import { GitHubRef } from '../common/githubRef'; import Logger from '../common/logger'; import { GitHubRemote } from '../common/remote'; import { ITelemetry } from '../common/telemetry'; +import { isObject } from '../common/utils'; interface RestResponse { headers: { @@ -35,15 +36,13 @@ export enum GraphQLErrorType { export interface GraphQLError { extensions?: { code: string; + typeName?: string; + fieldName?: string; }; type?: GraphQLErrorType; message?: string; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - /** * Detects whether an error from a REST (Octokit) or GraphQL (Apollo) call * indicates that the GitHub authentication token is no longer valid. This diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index c18fad70b5..3830bd1fb3 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -36,6 +36,7 @@ import { PullRequestCommentsResponse, PullRequestFilesResponse, PullRequestMergabilityResponse, + PullRequestStackResponse, ReactionGroup, ResolveReviewThreadResponse, ReviewThread, @@ -63,6 +64,7 @@ import { PullRequestChecks, PullRequestMergeability, PullRequestReviewRequirement, + PullRequestStack, ReadyForReview, ReviewEventEnum, } from './interface'; @@ -99,7 +101,7 @@ import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKe import { ITelemetry } from '../common/telemetry'; import { ClosedEvent, EventType, ReviewEvent, ReviewResolveInfo, TimelineEvent } from '../common/timelineEvent'; import { resolvePath, Schemes, toGitHubCommitUri, toPRUri, toReviewUri } from '../common/uri'; -import { formatError, isDescendant } from '../common/utils'; +import { formatError, isDescendant, isObject } from '../common/utils'; import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; interface IPullRequestModel { @@ -128,6 +130,34 @@ export type FileViewedState = { [key: string]: ViewedState }; type TreeDataMode = '100644' | '100755' | '120000'; const BATCH_SIZE = 50; +const STACK_MERGE_POLL_INTERVAL_MS = 3000; +const STACK_MERGE_TIMEOUT_MS = 5 * 60 * 1000; + +type AsyncMergeResponse = + | { status: 'pending'; details: { uuid: string; message: string } } + | { status: 'merged' | 'enqueued' | 'failed'; details: { message: string } }; + +export type StackMergeOutcome = 'merged' | 'enqueued' | 'pending' | 'cancelled'; + +function parseAsyncMergeResponse(value: unknown): AsyncMergeResponse { + if (!isObject(value)) { + throw new Error('GitHub returned an invalid stack merge result.'); + } + const { status, details } = value; + if (!isObject(details) || typeof details.message !== 'string') { + throw new Error('GitHub returned an invalid stack merge result.'); + } + if (status === 'pending') { + if (typeof details.uuid !== 'string' || !details.uuid) { + throw new Error('GitHub did not return a stack merge request ID.'); + } + return { status, details: { uuid: details.uuid, message: details.message } }; + } + if (status === 'merged' || status === 'enqueued' || status === 'failed') { + return { status, details: { message: details.message } }; + } + throw new Error('GitHub returned an unknown stack merge result.'); +} export class PullRequestModel extends IssueModel implements IPullRequestModel { static override ID = 'PullRequestModel'; @@ -421,46 +451,14 @@ export class PullRequestModel extends IssueModel implements IPullRe const { mutate, schema } = await this.githubRepository.ensure(); const workingDirectorySHA = repository.state.HEAD?.commit; - const mergingPRSHA = this.head?.sha; - const workingDirectoryIsDirty = repository.state.workingTreeChanges.length > 0; let expectedHeadOid: string | undefined = this.head?.sha; if (this.isActive) { // We're on the branch of the pr being merged. expectedHeadOid = workingDirectorySHA; - if (workingDirectorySHA !== mergingPRSHA) { - // We are looking at different commit than what will be merged - const { ahead } = repository.state.HEAD!; - const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this pull request branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); - const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this pull request branch.\n\nWould you like to proceed anyway?'); - if (ahead && - (await vscode.window.showWarningMessage( - ahead > 1 ? pluralMessage : singularMessage, - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined) { - - return { - merged: false, - message: vscode.l10n.t('unpushed changes'), - }; - } - } - - if (workingDirectoryIsDirty) { - // We have made changes to the PR that are not committed - if ( - (await vscode.window.showWarningMessage( - vscode.l10n.t('You have uncommitted changes on this pull request branch.\n\n Would you like to proceed anyway?'), - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined - ) { - return { - merged: false, - message: vscode.l10n.t('uncommitted changes'), - }; - } + const localChangesWarning = await this.confirmLocalChanges(repository); + if (localChangesWarning) { + return { merged: false, message: localChangesWarning }; } } const input: MergePullRequestInput = { @@ -504,6 +502,96 @@ export class PullRequestModel extends IssueModel implements IPullRe }); } + private async confirmLocalChanges(repository: Repository): Promise { + const localHead = repository.state.HEAD; + if (!localHead) { + throw new Error('The checked out pull request branch has no HEAD commit.'); + } + if (localHead.commit !== this.head?.sha) { + const ahead = localHead.ahead; + const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this pull request branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); + const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this pull request branch.\n\nWould you like to proceed anyway?'); + if (ahead && (await vscode.window.showWarningMessage( + ahead > 1 ? pluralMessage : singularMessage, + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined) { + return vscode.l10n.t('unpushed changes'); + } + } + if (repository.state.workingTreeChanges.length > 0 && (await vscode.window.showWarningMessage( + vscode.l10n.t('You have uncommitted changes on this pull request branch.\n\n Would you like to proceed anyway?'), + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined) { + return vscode.l10n.t('uncommitted changes'); + } + return; + } + + async mergeStack(repository: Repository, stack: PullRequestStack, method: MergeMethod, mergeAction: 'direct_merge' | 'merge_queue'): Promise { + if (!stack.pullRequests.some(entry => entry.number === this.number && entry.state === GithubItemStateEnum.Open)) { + throw new Error(`Pull request #${this.number} is not open in this stack.`); + } + const headSha = this.head?.sha; + if (!headSha) { + throw new Error(`Missing head commit for pull request #${this.number}.`); + } + if (this.isActive && await this.confirmLocalChanges(repository)) { + return 'cancelled'; + } + + const { octokit, remote } = await this.githubRepository.ensure(); + const route = '/repos/{owner}/{repo}/pulls/{pull_number}/merge-async'; + const parameters = { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + headers: { 'X-GitHub-Api-Version': '2026-03-10' }, + }; + let response: AsyncMergeResponse; + try { + const result = await octokit.call(() => octokit.api.request(`PUT ${route}`, { + ...parameters, + sha: headSha, + merge_action: mergeAction, + ...(mergeAction === 'direct_merge' ? { merge_method: method } : {}), + })); + response = parseAsyncMergeResponse(result.data); + } catch (error) { + if (!isObject(error) || error.status !== 409 || !isObject(error.response)) { + throw error; + } + const pending = parseAsyncMergeResponse(error.response.data); + if (pending.status !== 'pending') { + throw error; + } + response = pending; + } + + const deadline = Date.now() + STACK_MERGE_TIMEOUT_MS; + while (response.status === 'pending') { + const uuid = response.details.uuid; + if (Date.now() >= deadline) { + return 'pending'; + } + await new Promise(resolve => setTimeout(resolve, STACK_MERGE_POLL_INTERVAL_MS)); + const result = await octokit.call(() => octokit.api.request(`GET ${route}/{uuid}`, { + ...parameters, + uuid, + })); + response = parseAsyncMergeResponse(result.data); + } + if (response.status === 'failed') { + throw new Error(response.details?.message || 'GitHub could not merge this pull request stack.'); + } + if (response.status !== 'merged' && response.status !== 'enqueued') { + throw new Error('GitHub returned an unknown stack merge result.'); + } + Logger.debug(`Stack merge for #${this.number}: ${response.status}`, PullRequestModel.ID); + return response.status; + } + /** * Close the pull request. */ @@ -1620,6 +1708,76 @@ export class PullRequestModel extends IssueModel implements IPullRe return this.githubRepository.getStatusChecks(this.number); } + async getStack(): Promise { + const { query, remote, schema } = await this.githubRepository.ensure(); + let stack: PullRequestStack | undefined; + let after: string | null = null; + + while (true) { + let data: PullRequestStackResponse; + try { + ({ data } = await query({ + query: schema.PullRequestStack, + variables: { owner: remote.owner, name: remote.repositoryName, number: this.number, after }, + }, false, undefined, false)); + } catch (error) { + const graphQLErrors = isObject(error) ? error.graphQLErrors : undefined; + if (!stack && Array.isArray(graphQLErrors) && graphQLErrors.length > 0 && + graphQLErrors.every((graphQLError: unknown) => { + if (!isObject(graphQLError) || !isObject(graphQLError.extensions)) { + return false; + } + const { code, typeName, fieldName } = graphQLError.extensions; + return code === 'undefinedField' && typeName === 'PullRequest' && (fieldName === 'stack' || fieldName === 'stackEntry'); + })) { + Logger.debug('Pull request stacks are not supported by this GitHub server.', PullRequestModel.ID); + return; + } + throw error; + } + const pullRequest = data?.repository?.pullRequest; + if (!pullRequest) { + throw new Error(`Unable to load stack for pull request #${this.number}.`); + } + if (!pullRequest.stack) { + if (stack) { + throw new Error(`Stack changed while loading pull request #${this.number}.`); + } + return; + } + if (!pullRequest.stackEntry) { + throw new Error(`Missing stack position for pull request #${this.number}.`); + } + + stack ??= { + position: pullRequest.stackEntry.position, + size: pullRequest.stack.size, + base: pullRequest.stack.baseRefName, + pullRequests: [], + }; + stack.pullRequests.push(...pullRequest.stack.entries.nodes.map(entry => ({ + position: entry.position, + number: entry.pullRequest.number, + title: entry.pullRequest.title, + url: entry.pullRequest.url, + head: entry.pullRequest.headRefName, + state: entry.pullRequest.state, + isDraft: entry.pullRequest.isDraft, + mergeable: parseMergeability(entry.pullRequest.mergeable, entry.pullRequest.mergeStateStatus), + }))); + + const pageInfo = pullRequest.stack.entries.pageInfo; + if (!pageInfo.hasNextPage) { + stack.pullRequests.sort((a, b) => a.position - b.position); + return stack; + } + if (!pageInfo.endCursor || pageInfo.endCursor === after) { + throw new Error(`Missing next page of stack for pull request #${this.number}.`); + } + after = pageInfo.endCursor; + } + } + static async openChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, openToTheSide?: boolean): Promise { const changeModels = await PullRequestModel.getChangeModels(folderManager, pullRequestModel); const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index a99911b460..ce1d39ea88 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -40,7 +40,7 @@ import Logger from '../common/logger'; import { CHECKOUT_DEFAULT_BRANCH, CHECKOUT_PULL_REQUEST_BASE_BRANCH, DEFAULT_MERGE_METHOD, DELETE_BRANCH_AFTER_MERGE, POST_DONE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; -import { toOpenIssueWebviewUri } from '../common/uri'; +import { toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; import { asPromise, formatError } from '../common/utils'; import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { toCheckRunLogUri } from '../view/checkRunLogContentProvider'; @@ -501,6 +501,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { Logger.error(`Failed to update deferred pull request data: ${formatError(error)}`, PullRequestOverviewPanel.ID); }); + void pullRequestModel.getStack().then(async stack => { + if (updateSequence !== this._updateSequence) { + return; + } + const stackQueueMethod = stack ? await this._folderRepositoryManager.mergeQueueMethodForBranch(stack.base, pullRequest.remote.owner, pullRequest.remote.repositoryName) : undefined; + const linkedStack = stack && { + ...stack, + pullRequests: await Promise.all(stack.pullRequests.map(async entry => ({ + ...entry, + url: (await toOpenPullRequestWebviewUri({ + owner: pullRequest.remote.owner, + repo: pullRequest.remote.repositoryName, + pullRequestNumber: entry.number, + })).toString(), + }))), + }; + if (updateSequence === this._updateSequence) { + stackLoaded = true; + await this._postMessage({ + command: 'pr.update', + pullrequest: { + stack: linkedStack, + stackLoaded: true, + ...(stack ? { mergeQueueMethod: stackQueueMethod } : {}), + } satisfies Partial, + }); + } + }).catch(error => { + Logger.error(`Failed to load pull request stack: ${formatError(error)}`, PullRequestOverviewPanel.ID); + if (updateSequence === this._updateSequence) { + void this._postMessage({ command: 'pr.update', pullrequest: { stackLoadError: true } satisfies Partial }); + } + }); const timelineStart = performance.now(); void Promise.all([pullRequestModel.getTimelineEvents(), reviewRequestsPromise]).then(async ([latestTimelineEvents, requestedReviewers]) => { const events = latestTimelineEvents ?? []; @@ -662,6 +700,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + try { + const { item, folderRepositoryManager } = ctx; + const stack = await item.getStack(); + if (!stack) { + throw new Error(vscode.l10n.t('This pull request is no longer part of a stack. Refresh and try again.')); + } + const queueMethod = await folderRepositoryManager.mergeQueueMethodForBranch(stack.base, item.remote.owner, item.remote.repositoryName); + const status = await item.mergeStack(folderRepositoryManager.repository, stack, message.args.method, queueMethod ? 'merge_queue' : 'direct_merge'); + const result: StackMergeResult = { + status, + state: status === 'merged' ? GithubItemStateEnum.Merged : undefined, + }; + ctx.replyMessage(message, result); + + if (status === 'pending') { + void vscode.window.showInformationMessage(vscode.l10n.t('The stack merge is still processing on GitHub. Refresh the pull request to check its status.')); + } else if (status === 'enqueued') { + void vscode.window.showInformationMessage(vscode.l10n.t('The pull request stack has been added to the merge queue.')); + } + if (status === 'merged' || status === 'enqueued') { + void item.githubRepository.getPullRequest(item.number, 'mergeStack').then(refreshed => { + if (!refreshed) { + Logger.error(`Unable to refresh pull request #${item.number} after stack merge`, 'PullRequestReviewCommon'); + } + }, error => Logger.error(`Unable to refresh stack merge: ${formatError(error)}`, 'PullRequestReviewCommon')); + } + } catch (error) { + Logger.error(`Failed to merge pull request stack: ${formatError(error)}`, 'PullRequestReviewCommon'); + void vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request stack: {0}', formatError(error))); + ctx.throwError(message, formatError(error)); + } + } + /** * Find currently configured user's review status for the current PR */ diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql index 661c66da0c..14dd292a7a 100644 --- a/src/github/queriesShared.gql +++ b/src/github/queriesShared.gql @@ -718,6 +718,39 @@ query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { } } +query PullRequestStack($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + stackEntry { + position + } + stack { + size + baseRefName + entries(first: 100, after: $after) { + nodes { + position + pullRequest { + number + title + url + state + isDraft + headRefName + mergeable + mergeStateStatus + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } +} + query PullRequestState($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { diff --git a/src/github/views.ts b/src/github/views.ts index 3ad50bb543..dab2ea659d 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -16,10 +16,12 @@ import { PullRequestChecks, PullRequestMergeability, PullRequestReviewRequirement, + PullRequestStack, Reaction, ReviewState, StateReason, } from './interface'; +import type { StackMergeOutcome } from './pullRequestModel'; import { IComment } from '../common/comment'; import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; @@ -74,6 +76,10 @@ export interface Issue { } export interface PullRequest extends Issue { + stack?: PullRequestStack; + stackLoaded?: boolean; + stackLoadError?: boolean; + stackMergeStatus?: 'pending' | 'enqueued'; isCopilotOnMyBehalf: boolean; isAgentSessionsWorkspace: boolean; isCurrentlyCheckedOut: boolean; @@ -174,6 +180,11 @@ export interface MergeResult { events?: TimelineEvent[]; } +export interface StackMergeResult { + status: StackMergeOutcome; + state?: GithubItemStateEnum; +} + export interface DeleteReviewResult { deletedReviewId: number; deletedReviewComments: IComment[]; diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index 2fd1220ba8..c30abbb3fa 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -9,6 +9,15 @@ import { EventEmitter } from 'vscode'; import * as timers from 'timers'; describe('utils', () => { + describe('isObject', () => { + it('accepts non-null objects and rejects primitives', () => { + assert.strictEqual(utils.isObject({ status: 409 }), true); + assert.strictEqual(utils.isObject([]), true); + assert.strictEqual(utils.isObject(null), false); + assert.strictEqual(utils.isObject('text'), false); + }); + }); + class HookError extends Error { public errors: any[]; diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index bed96bce9e..55e0744c23 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -41,6 +41,31 @@ describe('GitHubRepository', function () { }); describe('query', function () { + it('does not switch schemas for an unsupported optional stack query', async function () { + const url = 'https://github.com/some/repo'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const repo = new GitHubRepository(1, remote, Uri.file('/workspaces/repo'), credentialStore, telemetry, true); + const graphql = sinon.createStubInstance(LoggingApolloClient); + sinon.stub(credentialStore, 'isAuthenticated').returns(true); + sinon.stub(repo, 'hub').get(() => ({ graphql, octokit: sinon.createStubInstance(LoggingOctokit) })); + const error = Object.assign(new Error("Field 'stack' doesn't exist on type 'PullRequest'"), { + graphQLErrors: [{ extensions: { code: 'undefinedField', typeName: 'PullRequest', fieldName: 'stack' } }], + }); + graphql.query.rejects(error); + + try { + await assert.rejects(repo.query({ + query: repo.schema.PullRequestStack, + variables: { owner: 'some', name: 'repo', number: 1, after: null }, + }, false, undefined, false), candidate => candidate === error); + + assert.strictEqual(repo.areQueriesLimited, false); + assert.strictEqual(graphql.query.callCount, 1); + } finally { + repo.dispose(); + } + }); + it('replaces variables for a legacy query with different arguments', async function () { const url = 'https://github.com/some/repo'; const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index 17dc84d257..408ea3f05d 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -11,7 +11,7 @@ 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 { GithubItemStateEnum, PullRequestMergeability, PullRequestStack } from '../../github/interface'; import { Protocol } from '../../common/protocol'; import { GitHubRemote, Remote } from '../../common/remote'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; @@ -19,6 +19,7 @@ import { SinonSandbox, createSandbox } from 'sinon'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { MockRepository } from '../mocks/mockRepository'; import { NetworkStatus } from 'apollo-client'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { GitHubServerType } from '../../common/authentication'; @@ -102,6 +103,299 @@ describe('PullRequestModel', function () { assert.strictEqual(open.state, GithubItemStateEnum.Merged); }); + describe('getStack', function () { + function createModel() { + const pr = new PullRequestBuilder().number(794).build(); + return new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + } + + function createEnterpriseModel() { + const enterpriseRemote = new GitHubRemote('enterprise', 'https://enterprise.example.com/github/test', new Protocol('https://enterprise.example.com/github/test'), GitHubServerType.Enterprise); + const enterpriseRepo = new MockGitHubRepository(enterpriseRemote, credentials, telemetry, sinon); + const pr = new PullRequestBuilder().number(794).build(); + const model = new PullRequestModel(credentials, telemetry, enterpriseRepo, enterpriseRemote, convertRESTPullRequestToRawPullRequest(pr, enterpriseRepo)); + return { enterpriseRepo, model }; + } + + function stackPage(entries: { + position: number; + number: number; + mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus?: 'CLEAN' | 'BLOCKED' | 'BEHIND' | 'DIRTY' | 'UNKNOWN'; + }[], endCursor: string | null, position = 2) { + return { + data: { + repository: { + pullRequest: { + stackEntry: { position }, + stack: { + size: 3, + baseRefName: 'main', + entries: { + nodes: entries.map(entry => ({ + position: entry.position, + pullRequest: { + number: entry.number, + title: `Change ${entry.number}`, + url: `https://github.com/github/test/pull/${entry.number}`, + state: GithubItemStateEnum.Open, + isDraft: false, + headRefName: `D${entry.position}`, + mergeable: entry.mergeable ?? 'MERGEABLE', + mergeStateStatus: entry.mergeStateStatus ?? 'CLEAN', + }, + })), + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + }, + }, + }, + }, + }, + loading: false, + stale: false, + networkStatus: NetworkStatus.ready, + }; + } + + it('loads every page and orders entries by position', async function () { + const model = createModel(); + const variables = { owner: 'github', name: 'test', number: 794 }; + repo.queryProvider.expectGraphQLQuery({ query: queries.PullRequestStack, variables: { ...variables, after: null } }, stackPage([ + { position: 3, number: 795 }, + { position: 2, number: 794 }, + ], 'next')); + repo.queryProvider.expectGraphQLQuery({ query: queries.PullRequestStack, variables: { ...variables, after: 'next' } }, stackPage([ + { position: 1, number: 793 }, + ], null)); + + const stack = await model.getStack(); + assert.strictEqual(stack?.position, 2); + assert.strictEqual(stack?.size, 3); + assert.strictEqual(stack?.base, 'main'); + assert.deepStrictEqual(stack?.pullRequests.map(entry => entry.number), [793, 794, 795]); + assert.strictEqual(stack?.pullRequests[0].url, 'https://github.com/github/test/pull/793'); + assert.deepStrictEqual(stack?.pullRequests.map(entry => entry.mergeable), [ + PullRequestMergeability.Mergeable, + PullRequestMergeability.Mergeable, + PullRequestMergeability.Mergeable, + ]); + }); + + it('uses merge conflicts and merge requirements to classify stack readiness', async function () { + const model = createModel(); + repo.queryProvider.expectGraphQLQuery({ + query: queries.PullRequestStack, + variables: { owner: 'github', name: 'test', number: 794, after: null }, + }, stackPage([ + { position: 1, number: 793, mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }, + { position: 2, number: 794, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' }, + { position: 3, number: 795, mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' }, + ], null)); + + const stack = await model.getStack(); + assert.deepStrictEqual(stack?.pullRequests.map(entry => entry.mergeable), [ + PullRequestMergeability.Conflict, + PullRequestMergeability.NotMergeable, + PullRequestMergeability.Behind, + ]); + }); + + describe('mergeStack', function () { + const route = '/repos/{owner}/{repo}/pulls/{pull_number}/merge-async'; + const stack: PullRequestStack = { + position: 2, + size: 2, + base: 'main', + pullRequests: [ + { position: 1, number: 793, title: 'First', url: '', head: 'D1', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + { position: 2, number: 794, title: 'Second', url: '', head: 'D2', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }; + + function createModel(): PullRequestModel { + const pr = new PullRequestBuilder().number(794).build(); + return new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + } + + function requestParams(model: PullRequestModel) { + return { + owner: 'github', + repo: 'test', + pull_number: 794, + headers: { 'X-GitHub-Api-Version': '2026-03-10' }, + sha: model.head?.sha, + merge_action: 'direct_merge', + merge_method: 'squash', + }; + } + + it('requests a direct stack merge with the selected method and head SHA', async function () { + const model = createModel(); + repo.queryProvider.expectOctokitRequest(['request'], [`PUT ${route}`, requestParams(model)], { + status: 'merged', + details: { message: 'Merged', sha: 'merge-sha' }, + }); + + assert.strictEqual(await model.mergeStack(new MockRepository(), stack, 'squash', 'direct_merge'), 'merged'); + }); + + it('queues a stack without supplying a direct merge method', async function () { + const model = createModel(); + const { merge_method, ...params } = requestParams(model); + repo.queryProvider.expectOctokitRequest(['request'], [`PUT ${route}`, { ...params, merge_action: 'merge_queue' }], { + status: 'enqueued', + details: { message: 'Added to merge queue' }, + }); + + assert.strictEqual(await model.mergeStack(new MockRepository(), stack, 'squash', 'merge_queue'), 'enqueued'); + }); + + it('polls an accepted merge request until it completes', async function () { + this.timeout(8000); + const model = createModel(); + repo.queryProvider.expectOctokitRequest(['request'], [`PUT ${route}`, requestParams(model)], { + status: 'pending', + details: { message: 'Processing', uuid: 'request-uuid' }, + }); + repo.queryProvider.expectOctokitRequest(['request'], [`GET ${route}/{uuid}`, { + owner: 'github', repo: 'test', pull_number: 794, + headers: { 'X-GitHub-Api-Version': '2026-03-10' }, uuid: 'request-uuid', + }], { + status: 'merged', + details: { message: 'Merged', sha: 'merge-sha' }, + }); + + assert.strictEqual(await model.mergeStack(new MockRepository(), stack, 'squash', 'direct_merge'), 'merged'); + }); + + it('reports a merge that remains pending after the polling timeout', async function () { + const model = createModel(); + const now = sinon.stub(Date, 'now'); + now.onFirstCall().returns(0); + now.onSecondCall().returns(5 * 60 * 1000); + repo.queryProvider.expectOctokitRequest(['request'], [`PUT ${route}`, requestParams(model)], { + status: 'pending', + details: { message: 'Processing', uuid: 'request-uuid' }, + }); + + assert.strictEqual(await model.mergeStack(new MockRepository(), stack, 'squash', 'direct_merge'), 'pending'); + assert(now.calledTwice); + }); + + it('polls an already-running merge request returned by GitHub', async function () { + this.timeout(8000); + const model = createModel(); + const parameters = requestParams(model); + repo.queryProvider.expectOctokitError(['request'], [`PUT ${route}`, parameters], Object.assign(new Error('Merge already requested'), { + status: 409, + response: { data: { status: 'pending', details: { message: 'Processing', uuid: 'existing-uuid' } } }, + })); + repo.queryProvider.expectOctokitRequest(['request'], [`GET ${route}/{uuid}`, { + owner: 'github', repo: 'test', pull_number: 794, + headers: { 'X-GitHub-Api-Version': '2026-03-10' }, uuid: 'existing-uuid', + }], { + status: 'enqueued', + details: { message: 'Added to merge queue' }, + }); + + assert.strictEqual(await model.mergeStack(new MockRepository(), stack, 'squash', 'direct_merge'), 'enqueued'); + }); + + it('surfaces a failed background merge instead of reporting success', async function () { + const model = createModel(); + repo.queryProvider.expectOctokitRequest(['request'], [`PUT ${route}`, requestParams(model)], { + status: 'failed', + details: { message: 'Required checks failed' }, + }); + + await assert.rejects(model.mergeStack(new MockRepository(), stack, 'squash', 'direct_merge'), /Required checks failed/); + }); + + it('refuses to merge a pull request that is no longer open in the stack', async function () { + const model = createModel(); + const invalidStack: PullRequestStack = { ...stack, pullRequests: [{ ...stack.pullRequests[1], state: GithubItemStateEnum.Merged }] }; + await assert.rejects(model.mergeStack(new MockRepository(), invalidStack, 'squash', 'direct_merge'), /not open in this stack/); + }); + }); + + it('returns no stack when the pull request is not stacked', async function () { + const model = createModel(); + repo.queryProvider.expectGraphQLQuery({ + query: queries.PullRequestStack, + variables: { owner: 'github', name: 'test', number: 794, after: null }, + }, { + data: { repository: { pullRequest: { stack: null, stackEntry: null } } }, + loading: false, + stale: false, + networkStatus: NetworkStatus.ready, + }); + + assert.strictEqual(await model.getStack(), undefined); + }); + + it('reports an incomplete stack page rather than showing a partial stack', async function () { + const model = createModel(); + const response = stackPage([{ position: 2, number: 794 }], null); + response.data.repository.pullRequest.stack.entries.pageInfo.hasNextPage = true; + repo.queryProvider.expectGraphQLQuery({ + query: queries.PullRequestStack, + variables: { owner: 'github', name: 'test', number: 794, after: null }, + }, response); + + await assert.rejects(model.getStack(), /Missing next page of stack/); + }); + + it('loads stacks from GitHub Enterprise when supported', async function () { + const { enterpriseRepo, model } = createEnterpriseModel(); + try { + enterpriseRepo.queryProvider.expectGraphQLQuery({ + query: queries.PullRequestStack, + variables: { owner: 'github', name: 'test', number: 794, after: null }, + }, stackPage([{ position: 2, number: 794 }], null)); + + assert.strictEqual((await model.getStack())?.pullRequests[0].number, 794); + } finally { + enterpriseRepo.dispose(); + } + }); + + it('uses the normal pull request flow on Enterprise without stack fields, then detects support on retry', async function () { + const { enterpriseRepo, model } = createEnterpriseModel(); + try { + const query = sinon.stub(enterpriseRepo, 'query'); + const unsupported = Object.assign(new Error("Field 'stack' doesn't exist on type 'PullRequest'"), { + graphQLErrors: [{ + extensions: { code: 'undefinedField', typeName: 'PullRequest', fieldName: 'stack' }, + }], + }); + query.onFirstCall().rejects(unsupported); + query.onSecondCall().resolves(stackPage([{ position: 2, number: 794 }], null)); + + assert.strictEqual(await model.getStack(), undefined); + assert.strictEqual((await model.getStack())?.pullRequests[0].number, 794); + assert(query.calledTwice); + } finally { + enterpriseRepo.dispose(); + } + }); + + it('does not hide other GraphQL errors while fetching the stack', async function () { + const { enterpriseRepo, model } = createEnterpriseModel(); + try { + const error = Object.assign(new Error("Field 'mergeable' doesn't exist on type 'PullRequest'"), { + graphQLErrors: [{ + extensions: { code: 'undefinedField', typeName: 'PullRequest', fieldName: 'mergeable' }, + }], + }); + sinon.stub(enterpriseRepo, 'query').rejects(error); + + await assert.rejects(model.getStack(), error); + } finally { + enterpriseRepo.dispose(); + } + }); + }); + describe('openReadonlyChanges', function () { const baseCommit = '1111111111111111111111111111111111111111'; const mergeBase = '2222222222222222222222222222222222222222'; diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index 959e52b15e..33c4d28903 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -23,12 +23,12 @@ import { GitApiImpl } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; import { GitHubServerType } from '../../common/authentication'; import { GitHubRemote } from '../../common/remote'; -import { CheckState, GithubItemStateEnum } from '../../github/interface'; +import { CheckState, GithubItemStateEnum, PullRequestMergeability, PullRequestStack } from '../../github/interface'; import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; import { TimelineEvent } from '../../common/timelineEvent'; -import { PullRequestReviewCommon } from '../../github/pullRequestReviewCommon'; +import { PullRequestReviewCommon, ReviewContext } from '../../github/pullRequestReviewCommon'; const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); @@ -327,6 +327,63 @@ describe('PullRequestOverview', function () { }); }); + describe('mergeStack', function () { + const stack: PullRequestStack = { + position: 2, + size: 2, + base: 'production', + pullRequests: [ + { position: 1, number: 999, title: 'First', url: '', head: 'D1', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + { position: 2, number: 1000, title: 'Second', url: '', head: 'D2', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }; + + function createMergeContext() { + const item = new PullRequestModel(credentialStore, telemetry, repo, remote, + convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo)); + sinon.stub(item, 'getStack').resolves(stack); + return { + item, + folderRepositoryManager: pullRequestManager, + existingReviewers: [], + postMessage: sinon.stub().resolves(), + replyMessage: sinon.spy(), + throwError: sinon.spy(), + getTimeline: sinon.stub().resolves([]), + } satisfies ReviewContext; + } + + it('uses the stack target branch merge queue and reports enqueue without marking the PR merged', async function () { + const ctx = createMergeContext(); + sinon.stub(repo, 'getPullRequest').resolves(ctx.item); + const queue = sinon.stub(pullRequestManager, 'mergeQueueMethodForBranch').resolves('squash'); + const merge = sinon.stub(ctx.item, 'mergeStack').resolves('enqueued'); + const information = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); + const message = { req: '1', command: 'pr.merge-stack', args: { method: 'squash' as const } }; + + await PullRequestReviewCommon.mergeStack(ctx, message); + + assert(queue.calledOnceWithExactly('production', remote.owner, remote.repositoryName)); + assert(merge.calledOnceWithExactly(pullRequestManager.repository, stack, 'squash', 'merge_queue')); + sinon.assert.calledWithExactly(ctx.replyMessage, message, { status: 'enqueued', state: undefined }); + assert(information.calledOnce); + assert(ctx.throwError.notCalled); + }); + + it('reports a rejected merge rather than sending a successful response', async function () { + const ctx = createMergeContext(); + sinon.stub(ctx.item, 'mergeStack').rejects(new Error('Required checks failed')); + const showError = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const message = { req: '2', command: 'pr.merge-stack', args: { method: 'merge' as const } }; + + await PullRequestReviewCommon.mergeStack(ctx, message); + + assert(showError.calledOnce); + assert(ctx.replyMessage.notCalled); + sinon.assert.calledWithExactly(ctx.throwError, message, 'Required checks failed'); + }); + }); + const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); const identity = { owner: prModel.remote.owner, repo: prModel.remote.repositoryName, number: prModel.number }; diff --git a/src/test/mocks/queryProvider.ts b/src/test/mocks/queryProvider.ts index 5a3834d393..3f9509dfb4 100644 --- a/src/test/mocks/queryProvider.ts +++ b/src/test/mocks/queryProvider.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { inspect } from 'util'; import { Octokit } from '@octokit/rest'; import { @@ -8,7 +13,7 @@ import { MutationOptions, FetchResult, } from 'apollo-boost'; -import { SinonSandbox, SinonStubbedInstance } from 'sinon'; +import { SinonSandbox, SinonStub, SinonStubbedInstance } from 'sinon'; import equals from 'fast-deep-equal'; interface RecordedQueryResult { @@ -69,6 +74,17 @@ export class QueryProvider { } expectOctokitRequest(accessorPath: string[], args: any[], response: R) { + this.getOctokitRequestStub(accessorPath).withArgs(...args).resolves({ + data: response, + headers: { 'x-ratelimit-limit': '5000', 'x-ratelimit-remaining': '4999' }, + }); + } + + expectOctokitError(accessorPath: string[], args: any[], error: Error) { + this.getOctokitRequestStub(accessorPath).withArgs(...args).rejects(error); + } + + private getOctokitRequestStub(accessorPath: string[]): SinonStub { let currentStub: SinonStubbedInstance = this._octokit; accessorPath.forEach((accessor, i) => { let nextStub = currentStub[accessor]; @@ -87,7 +103,7 @@ export class QueryProvider { } currentStub = nextStub; }); - currentStub.withArgs(...args).resolves({ data: response }); + return currentStub as SinonStub; } emulateGraphQLQuery(q: QueryOptions): ApolloQueryResult { diff --git a/webviews/activityBarView/index.css b/webviews/activityBarView/index.css index 94330d9f20..e4fa3f69c8 100644 --- a/webviews/activityBarView/index.css +++ b/webviews/activityBarView/index.css @@ -38,6 +38,33 @@ textarea { padding: 16px 0; } +#status-checks .stack-merge { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; + gap: 8px; + width: 100%; + max-width: 260px; + margin: 0 auto; + padding: 0; + background: transparent; +} + +#status-checks .stack-merge > button { + width: 100%; +} + +#status-checks .stack-merge .stack-merge-method select { + flex: 1; + min-width: 0; +} + +#status-checks .stack-merge p { + margin: 0; +} + .comment-form { padding-bottom: 16px; } diff --git a/webviews/common/common.css b/webviews/common/common.css index 945a0946bb..e627c12ff9 100644 --- a/webviews/common/common.css +++ b/webviews/common/common.css @@ -385,6 +385,12 @@ body img.avatar { flex-wrap: wrap; } +.stack-merge-method { + display: flex; + align-items: center; + gap: 8px; +} + #status-checks .automerge-section { align-items: center; padding: 16px; @@ -393,6 +399,10 @@ body img.avatar { border-bottom-right-radius: 6px; } +#status-checks .stack-merge { + gap: 8px; +} + .automerge-section .merge-select-container { margin-left: 8px; } diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 73e3aae5cf..6b41bc635c 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -11,7 +11,7 @@ import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArg import { IComment } from '../../src/common/comment'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; -import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewArgs, SubmitReviewReply, UploadFilesReply } from '../../src/github/views'; +import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, StackMergeResult, SubmitReviewArgs, SubmitReviewReply, UploadFilesReply } from '../../src/github/views'; /** * Encode a {@linkcode Uint8Array} as a base64 string. Uses fixed-size chunks to @@ -98,6 +98,9 @@ export class PRContext { return result; }; + public mergeStack = (method: MergeMethod): Promise => + this.postMessage({ command: 'pr.merge-stack', args: { method } }); + public openOnGitHub = () => this.postMessage({ command: 'pr.openOnGitHub' }); public deleteBranch = async () => { diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index 9e04929ead..a29a308dbb 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -6,10 +6,11 @@ import React, { useContext, useState } from 'react'; import { ContextDropdown } from './contextDropdown'; import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, diffMultipleIcon, editIcon, gitMergeIcon, gitPullRequestClosedIcon, gitPullRequestDraftIcon, gitPullRequestIcon, issuescon, loadingIcon, passIcon } from './icon'; +import { StackBadge } from './pullRequestStack'; import { AuthorLink, Avatar } from './user'; import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../../src/common/copilot'; import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent'; -import { GithubItemStateEnum, StateReason } from '../../src/github/interface'; +import { GithubItemStateEnum, PullRequestStack, StateReason } from '../../src/github/interface'; import { BaseContext, CodingAgentContext, OverviewContext, PullRequest } from '../../src/github/views'; import { EDIT_TITLE_BUTTON_ID } from '../common/constants'; import PullRequestContext from '../common/context'; @@ -34,7 +35,8 @@ export function Header({ owner, repo, busy, - stateReason + stateReason, + stack, }: PullRequest) { const [currentTitle, setCurrentTitle] = useStateProp(title); const [inEditMode, setEditMode] = useState(false); @@ -56,7 +58,7 @@ export function Header({ owner={owner} repo={repo} /> - +
{icon} {text}
+ {!isIssue ? : null}
{}
diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index b7a1b3f26a..fbd30d8b37 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -34,6 +34,7 @@ export const gitPullRequestClosedIcon = ; export const gitPullRequestIcon = ; export const issuescon = ; +export const layersIcon = ; export const loadingIcon = ; export const milestoneIcon = ; export const notebookTemplate = ; diff --git a/webviews/components/merge.tsx b/webviews/components/merge.tsx index c8d7d31ced..4d793c8444 100644 --- a/webviews/components/merge.tsx +++ b/webviews/components/merge.tsx @@ -16,6 +16,7 @@ import { AutoMerge, QueuedToMerge } from './automergeSelect'; import { ContextDropdown } from './contextDropdown'; import { Dropdown } from './dropdown'; import { checkIcon, circleFilledIcon, closeIcon, gitMergeIcon, loadingIcon, outputIcon, requestChangesIcon, skipIcon, warningIcon } from './icon'; +import { StackSection } from './pullRequestStack'; import { nbsp } from './space'; import { Avatar } from './user'; import { EventType, ReviewEvent } from '../../src/common/timelineEvent'; @@ -156,6 +157,7 @@ export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimpl + {!isSimple ? : null} @@ -401,18 +403,34 @@ export const Merge = (pr: PullRequest) => { export const PrActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { const { hasWritePermission, canEdit, isDraft, mergeable, isCopilotOnMyBehalf, defaultMergeMethod } = pr; + if (pr.stackLoadError) { + return
Unable to load pull request stack details. Refresh to retry merging.
; + } + if (pr.stackLoaded === false) { + return
Checking pull request stack membership...
; + } if (isDraft) { // Only PR author and users with push rights can mark draft as ready for review if (!canEdit) { return null; } - return ; + return ; + } + + if (pr.stackMergeStatus === 'pending') { + return
Stack merge is still processing on GitHub. Refresh to check its status.
; + } + if (pr.stackMergeStatus === 'enqueued') { + return
Pull request stack added to the merge queue.
; + } + if (pr.stack && hasWritePermission && !pr.mergeQueueEntry) { + return ; } - if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { + if (!pr.stack && mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { return isSimple ? : ; - } else if (!isSimple && hasWritePermission && !pr.mergeQueueEntry) { + } else if (!pr.stack && !isSimple && hasWritePermission && !pr.mergeQueueEntry) { const ctx = useContext(PullRequestContext); return ( { - const { openOnGitHub } = useContext(PullRequestContext); - return ( - - ); +export const MergeStack = ({ pr }: { pr: PullRequest }) => { + const { mergeStack, updatePR } = useContext(PullRequestContext); + const [method, setMethod] = useState(pr.defaultMergeMethod); + const [confirm, setConfirm] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + if (!pr.stack) { + throw new Error('A pull request stack is required for stack merge actions.'); + } + const count = 1 + pr.stack.pullRequests.filter(entry => entry.position < pr.stack.position && entry.state === GithubItemStateEnum.Open).length; + const downstack = count - 1; + const downstackDescription = `${downstack} open pull request${downstack === 1 ? '' : 's'} below it`; + const label = pr.mergeQueueMethod ? 'Add stack to merge queue' : `Merge stack (${count} pull request${count === 1 ? '' : 's'})`; + + const submit = async () => { + try { + setBusy(true); + setError(undefined); + const result = await mergeStack(method); + if (result.status !== 'cancelled') { + updatePR({ + state: result.state ?? pr.state, + revertable: result.status === 'merged', + stackMergeStatus: result.status === 'pending' || result.status === 'enqueued' ? result.status : undefined, + }); + } + setConfirm(false); + } catch (mergeError) { + setError(mergeError instanceof Error ? mergeError.message || mergeError.name : String(mergeError)); + } finally { + setBusy(false); + } + }; + + return
+ {confirm ? <> +

{pr.mergeQueueMethod + ? downstack ? `Add this pull request and ${downstackDescription} to the merge queue?` : 'Add this pull request to the merge queue?' + : downstack ? `Merge this pull request and ${downstackDescription}?` : 'Merge this pull request?'}

+ {error ?

Unable to merge stack: {error}

: null} + + + : <> + + {!pr.mergeQueueMethod ?
+ using method + setMethod(event.target.value as MergeMethod)} /> +
: null} + } +
; }; export const MergeSimple = (pr: PullRequest) => { diff --git a/webviews/components/pullRequestStack.tsx b/webviews/components/pullRequestStack.tsx new file mode 100644 index 0000000000..0c63616719 --- /dev/null +++ b/webviews/components/pullRequestStack.tsx @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { checkIcon, chevronDownIcon, circleFilledIcon, closeIcon, layersIcon, warningIcon } from './icon'; +import { GithubItemStateEnum, PullRequestMergeability, PullRequestStack as Stack } from '../../src/github/interface'; +import { PullRequest } from '../../src/github/views'; + +function getReadiness(entry: Stack['pullRequests'][number]): { icon: JSX.Element; label: string; kind: string } { + if (entry.state === GithubItemStateEnum.Merged) { + return { icon: checkIcon, label: 'Already merged', kind: 'ready' }; + } + if (entry.state === GithubItemStateEnum.Closed) { + return { icon: closeIcon, label: 'Closed pull request cannot be merged', kind: 'blocked' }; + } + if (entry.isDraft) { + return { icon: warningIcon, label: 'Draft pull request cannot be merged', kind: 'waiting' }; + } + switch (entry.mergeable) { + case PullRequestMergeability.Mergeable: + return { icon: checkIcon, label: 'Ready to merge', kind: 'ready' }; + case PullRequestMergeability.Conflict: + return { icon: closeIcon, label: 'Merge conflicts', kind: 'blocked' }; + case PullRequestMergeability.NotMergeable: + return { icon: closeIcon, label: 'Merge requirements not met', kind: 'blocked' }; + case PullRequestMergeability.Behind: + return { icon: warningIcon, label: 'Branch is behind its base', kind: 'waiting' }; + default: + return { icon: circleFilledIcon, label: 'Mergeability is being checked', kind: 'waiting' }; + } +} + +export const StackBadge = ({ stack }: { stack?: Stack }) => stack ? ( + + {layersIcon} + {stack.position}/{stack.size} + +) : null; + +export const StackSection = ({ pr }: { pr: PullRequest }) => { + const { stack } = pr; + if (!stack) { + return null; + } + const openBelow = stack.pullRequests.filter(entry => entry.position < stack.position && entry.state === GithubItemStateEnum.Open).length; + + return ( +
+ + {layersIcon} + + Pull request stack + + {pr.state === GithubItemStateEnum.Open && openBelow > 0 + ? `Merging this pull request will also merge ${openBelow} pull request${openBelow === 1 ? '' : 's'} below it.` + : `${stack.size} pull requests in this stack.`} + + + {chevronDownIcon} + +
    + {[...stack.pullRequests].reverse().map(entry => { + const current = entry.number === pr.number; + const readiness = getReadiness(entry); + + return
  1. + {readiness.icon} + + {current ? {entry.title} : {entry.title}} + #{entry.number} - {entry.head} + +
  2. ; + })} +
  3. +
  4. +
+
+ ); +}; diff --git a/webviews/editorWebview/index.css b/webviews/editorWebview/index.css index 157d6f561f..8299b3b2af 100644 --- a/webviews/editorWebview/index.css +++ b/webviews/editorWebview/index.css @@ -633,6 +633,141 @@ body button .icon { align-items: center; flex-wrap: wrap; row-gap: 12px; + column-gap: 8px; +} + +.stack-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--vscode-panel-border); + border-radius: 16px; + color: var(--vscode-foreground); + text-decoration: none; +} + +.stack-badge:hover { + color: var(--vscode-textLink-activeForeground); +} + +.stack-badge .icon svg, +.stack-section summary .icon svg, +.stack-entry-readiness svg { + width: 16px; + height: 16px; +} + +.stack-section summary { + cursor: pointer; +} + +.stack-section { + scroll-margin-top: 64px; +} + +.stack-section summary > .icon { + flex-shrink: 0; +} + +.stack-chevron { + margin-left: auto; +} + +.stack-section:not([open]) .stack-chevron { + transform: rotate(-90deg); +} + +.stack-description, +.stack-entry-meta { + display: block; + color: var(--vscode-descriptionForeground); + font-size: 0.9em; +} + +.stack-entries { + list-style: none; + margin: 0; + padding: 8px 16px; +} + +.stack-entry, +.stack-base { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + column-gap: 8px; + position: relative; + padding: 8px; +} + +.stack-entry::before { + content: ''; + position: absolute; + left: 17px; + top: 30px; + bottom: 0; + border-left: 1px solid var(--vscode-descriptionForeground); + opacity: 0.65; +} + +.stack-entry.current { + background: var(--vscode-panel-background); + border-radius: 4px; + box-shadow: inset 3px 0 var(--vscode-focusBorder); +} + +.stack-entry-readiness { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + position: relative; + z-index: 1; + border-radius: 50%; + background: var(--vscode-editor-background); +} + +.stack-entry.current .stack-entry-readiness { + background: var(--vscode-panel-background); +} + +.stack-entry-readiness.ready { + color: var(--vscode-issues-open); +} + +.stack-entry-readiness.ready svg path { + fill: var(--vscode-issues-open); +} + +.stack-entry-readiness.blocked { + color: var(--vscode-errorForeground); +} + +.stack-entry-readiness.waiting { + color: var(--vscode-list-warningForeground); +} + +.stack-entry-details { + min-width: 0; + overflow-wrap: anywhere; +} + +.stack-base { + align-items: center; +} + +.stack-base-marker { + box-sizing: border-box; + width: 8px; + height: 8px; + margin-left: 5px; + border: 2px solid var(--vscode-descriptionForeground); + border-radius: 50%; +} + +.stack-base .branch-tag { + width: fit-content; } .subtitle .avatar, @@ -716,7 +851,6 @@ button.secondary.change-base { color: var(--vscode-button-foreground); border-radius: 18px; padding: 4px 12px; - margin-right: 10px; font-weight: 600; display: flex; gap: 4px; diff --git a/webviews/editorWebview/test/builder/pullRequest.ts b/webviews/editorWebview/test/builder/pullRequest.ts index 265316eec6..b764bb8656 100644 --- a/webviews/editorWebview/test/builder/pullRequest.ts +++ b/webviews/editorWebview/test/builder/pullRequest.ts @@ -29,6 +29,10 @@ export const PullRequestBuilder = createBuilderClass()({ isRemoteHeadDeleted: { default: false }, isLocalHeadDeleted: { default: false }, head: { default: 'my-fork:my-branch' }, + stack: { default: undefined }, + stackLoaded: { default: true }, + stackLoadError: { default: false }, + stackMergeStatus: { default: undefined }, labels: { default: [] }, isAuthor: { default: true }, commitsCount: { default: 10 }, diff --git a/webviews/editorWebview/test/overview.test.tsx b/webviews/editorWebview/test/overview.test.tsx index 6a23cae97d..e4cadbab10 100644 --- a/webviews/editorWebview/test/overview.test.tsx +++ b/webviews/editorWebview/test/overview.test.tsx @@ -5,9 +5,10 @@ import { default as assert } from 'assert'; import * as React from 'react'; -import { cleanup, fireEvent, render } from 'react-testing-library'; +import { cleanup, fireEvent, render, waitForElement } from 'react-testing-library'; import { createSandbox, SinonSandbox } from 'sinon'; +import { GithubItemStateEnum, PullRequestMergeability } from '../../../src/github/interface'; import { PRContext, default as PullRequestContext } from '../../common/context'; import { Overview } from '../overview'; import { PullRequestBuilder } from './builder/pullRequest'; @@ -59,6 +60,243 @@ describe('Overview', function () { assert.strictEqual(openOnGitHub.callCount, 2); }); + it('shows the stack position and ordered pull requests in the merge section', function () { + const pr = new PullRequestBuilder().number(794).stack({ + position: 2, + size: 3, + base: 'main', + pullRequests: [ + { position: 1, number: 793, title: 'First Change', head: 'D1', url: 'https://example.com/793', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + { position: 2, number: 794, title: 'Second Change', head: 'D2', url: 'https://example.com/794', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.NotMergeable }, + { position: 3, number: 795, title: 'Third Change', head: 'D3', url: 'https://example.com/795', state: GithubItemStateEnum.Open, isDraft: true, mergeable: PullRequestMergeability.Mergeable }, + ], + }).build(); + const context = new PRContext(pr); + const mergeStack = sinon.stub(context, 'mergeStack').resolves({ status: 'merged', state: GithubItemStateEnum.Merged }); + const openOnGitHub = sinon.stub(context, 'openOnGitHub'); + + const out = render( + + + , + ); + + const badge = out.container.querySelector('.stack-badge'); + assert.strictEqual(badge?.getAttribute('href'), '#pull-request-stack'); + assert.strictEqual(badge?.getAttribute('title'), 'View pull request stack (2 of 3)'); + assert.strictEqual(badge?.textContent?.trim(), '2/3'); + const section = out.container.querySelector('#pull-request-stack'); + assert(section?.hasAttribute('open')); + assert(section.textContent?.includes('also merge 1 pull request below it.')); + assert.deepStrictEqual([...section.querySelectorAll('.stack-entry')].map(entry => entry.querySelector('.stack-entry-details')?.textContent), [ + 'Third Change#795 - D3', + 'Second Change#794 - D2', + 'First Change#793 - D1', + ]); + assert.deepStrictEqual([...section.querySelectorAll('.stack-entry-readiness')].map(entry => [entry.classList[1], entry.getAttribute('aria-label')]), [ + ['waiting', 'Draft pull request cannot be merged'], + ['blocked', 'Merge requirements not met'], + ['ready', 'Ready to merge'], + ]); + assert.deepStrictEqual([...section.querySelectorAll('.stack-entry-readiness')].map(entry => entry.getAttribute('title')), [ + 'Draft pull request cannot be merged', + 'Merge requirements not met', + 'Ready to merge', + ]); + assert.strictEqual(section.querySelector('.stack-entry-state'), null); + assert.strictEqual(section.querySelectorAll('.stack-entry').length, 3); + assert.strictEqual(section.querySelectorAll('.stack-entry-readiness svg').length, 3); + assert.strictEqual(section.querySelector('.stack-base-marker')?.getAttribute('aria-hidden'), 'true'); + assert.strictEqual(section.querySelector('.stack-entries')?.lastElementChild?.className, 'stack-base'); + assert.strictEqual(section.querySelector('.stack-entries')?.getAttribute('aria-label'), 'Pull requests merging down into main'); + assert.strictEqual(section.querySelector('.stack-entry.current')?.getAttribute('aria-current'), 'step'); + assert.strictEqual(section.querySelector('.stack-entry.current a'), null); + assert.strictEqual(section.querySelector('a[href="https://example.com/793"]')?.textContent, 'First Change'); + assert.strictEqual(section.querySelector('.stack-base')?.textContent, 'main'); + assert.strictEqual(out.container.querySelector('#merge-comment-form'), null); + assert(out.container.querySelector('.stack-merge .stack-merge-method select')); + const mergeButton = out.getByText('Merge stack (2 pull requests)'); + fireEvent.change(out.getByLabelText('Select merge method'), { target: { value: 'squash' } }); + fireEvent.click(mergeButton); + assert(out.getByText('Merge this pull request and 1 open pull request below it?')); + fireEvent.click(out.getByText('Merge stack (2 pull requests)')); + assert(mergeStack.calledOnceWithExactly('squash')); + assert(openOnGitHub.notCalled); + }); + + it('does not show a stack badge or section for an unstacked pull request', function () { + const pr = new PullRequestBuilder().build(); + const out = render( + + + , + ); + + assert.strictEqual(out.container.querySelector('.stack-badge'), null); + assert.strictEqual(out.container.querySelector('#pull-request-stack'), null); + assert(out.getByText('Merge Pull Request')); + }); + + it('shows a closed stack without suggesting it can be merged', function () { + const pr = new PullRequestBuilder().state(GithubItemStateEnum.Closed).stack({ + position: 1, + size: 2, + base: 'main', + pullRequests: [ + { position: 1, number: 1234, title: 'First Change', head: 'D1', url: 'https://example.com/1234', state: GithubItemStateEnum.Closed, isDraft: false, mergeable: PullRequestMergeability.Unknown }, + { position: 2, number: 1235, title: 'Second Change', head: 'D2', url: 'https://example.com/1235', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Unknown }, + ], + }).build(); + const out = render( + + + , + ); + + assert(out.container.querySelector('#pull-request-stack')?.textContent?.includes('2 pull requests in this stack.')); + assert.deepStrictEqual([...out.container.querySelectorAll('.stack-entry-readiness')].map(entry => entry.getAttribute('aria-label')), [ + 'Mergeability is being checked', + 'Closed pull request cannot be merged', + ]); + assert.strictEqual(out.container.querySelector('.automerge-section'), null); + }); + + it('does not count already merged pull requests in the merge impact', function () { + const pr = new PullRequestBuilder().number(795).stack({ + position: 3, + size: 3, + base: 'main', + pullRequests: [ + { position: 1, number: 793, title: 'First Change', head: 'D1', url: 'https://example.com/793', state: GithubItemStateEnum.Merged, isDraft: false, mergeable: PullRequestMergeability.Unknown }, + { position: 2, number: 794, title: 'Second Change', head: 'D2', url: 'https://example.com/794', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Unknown }, + { position: 3, number: 795, title: 'Third Change', head: 'D3', url: 'https://example.com/795', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Behind }, + ], + }).build(); + const out = render( + + + , + ); + + assert(out.container.querySelector('#pull-request-stack')?.textContent?.includes('also merge 1 pull request below it.')); + assert.deepStrictEqual([...out.container.querySelectorAll('.stack-entry-readiness')].map(entry => entry.getAttribute('aria-label')), [ + 'Branch is behind its base', + 'Mergeability is being checked', + 'Already merged', + ]); + }); + + it('does not offer to merge a stack without write permission', function () { + const pr = new PullRequestBuilder().hasWritePermission(false).stack({ + position: 1, + size: 1, + base: 'main', + pullRequests: [ + { position: 1, number: 1234, title: 'First Change', head: 'D1', url: 'https://example.com/1234', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }).build(); + const out = render( + + + , + ); + + assert(out.container.querySelector('#pull-request-stack')); + assert.strictEqual(out.container.querySelector('.automerge-section'), null); + assert.strictEqual(out.container.querySelector('#merge-comment-form'), null); + }); + + it('offers to queue a stack without showing a direct merge method', function () { + const pr = new PullRequestBuilder().mergeQueueMethod('squash').stack({ + position: 1, + size: 1, + base: 'main', + pullRequests: [ + { position: 1, number: 1234, title: 'First Change', head: 'D1', url: 'https://example.com/1234', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }).build(); + const out = render( + + + , + ); + + assert(out.getByText('Add stack to merge queue')); + assert.strictEqual(out.container.querySelector('.automerge-section select'), null); + }); + + it('keeps the PR open when an asynchronous stack merge is pending', async function () { + const pr = new PullRequestBuilder().stack({ + position: 1, + size: 1, + base: 'main', + pullRequests: [ + { position: 1, number: 1234, title: 'First Change', head: 'D1', url: 'https://example.com/1234', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }).build(); + const context = new PRContext(pr); + const mergeStack = sinon.stub(context, 'mergeStack').resolves({ status: 'pending' }); + const out = render( + + + , + ); + + fireEvent.click(out.getByText('Merge stack (1 pull request)')); + fireEvent.click(out.getByText('Merge stack (1 pull request)')); + await Promise.resolve(); + + assert(mergeStack.calledOnceWithExactly('merge')); + assert.strictEqual(context.pr?.state, GithubItemStateEnum.Open); + assert.strictEqual(context.pr?.stackMergeStatus, 'pending'); + }); + + it('shows stack merge failures without marking the pull request merged', async function () { + const pr = new PullRequestBuilder().stack({ + position: 1, + size: 1, + base: 'main', + pullRequests: [ + { position: 1, number: 1234, title: 'First Change', head: 'D1', url: 'https://example.com/1234', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable }, + ], + }).build(); + const context = new PRContext(pr); + const mergeStack = sinon.stub(context, 'mergeStack').rejects(new Error('Required checks failed')); + const out = render( + + + , + ); + + fireEvent.click(out.getByText('Merge stack (1 pull request)')); + fireEvent.click(out.getByText('Merge stack (1 pull request)')); + + assert(mergeStack.calledOnce); + assert.strictEqual((await waitForElement(() => out.container.querySelector('[role="alert"]')))?.textContent, 'Unable to merge stack: Required checks failed'); + assert.strictEqual(context.pr?.state, GithubItemStateEnum.Open); + assert.strictEqual(out.getByText('Merge stack (1 pull request)').hasAttribute('disabled'), false); + }); + + it('does not offer a legacy merge while stack membership is loading or failed', function () { + const pr = new PullRequestBuilder().stackLoaded(false).build(); + const out = render( + + + , + ); + assert(out.getByText('Checking pull request stack membership...')); + assert.strictEqual(out.container.querySelector('.automerge-section'), null); + + const failed = { ...pr, stackLoaded: true, stackLoadError: true }; + out.rerender( + + + , + ); + assert(out.getByText('Unable to load pull request stack details. Refresh to retry merging.')); + assert.strictEqual(out.container.querySelector('.automerge-section'), null); + }); + it('shows view changes in both headers', function () { const pr = new PullRequestBuilder().isAgentSessionsWorkspace(true).build(); const context = new PRContext(pr);