diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index d099408d022f..761a03f27018 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -310,6 +310,53 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + it('enqueues when a required merge queue disallows auto-merge', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { repository: { mergeQueue: { id: 'MQ_kw' }, autoMergeAllowed: false } } + }) + }) + .mockResolvedValue({ stdout: '', stderr: '' }) + + await expect( + setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true, enqueued: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 4, + expect.arrayContaining([ + 'api', + 'graphql', + '-f', + 'pullRequestId=PR_kwDO123', + '-f', + 'expectedHeadOid=head-oid' + ]), + expect.objectContaining({ + cwd: '/repo-root', + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), + host: 'github.com' + }) + ) + const enqueueCall = ghExecFileAsyncMock.mock.calls[3]?.[0] as string[] + expect(enqueueCall.some((arg) => arg.includes('enqueuePullRequest'))).toBe(true) + expect( + ghExecFileAsyncMock.mock.calls.some( + (call) => + call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto') + ) + ).toBe(false) + }) + it('blocks direct merge when GitHub reports required approval', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index f0c227d05e7f..f0b4a5dea2d1 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -10,6 +10,7 @@ import { githubHostExecOptions, type GitHubApiRepository } from '../../github-ap import type { GhExecOptions } from './../github-exec-scope' import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates' import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { readPullRequestInMergeQueue } from './../lookup/pull-request-merge-queue-membership' import { WORK_ITEM_PR_DETAIL_JSON_FIELDS, usersFromUnknown, @@ -121,9 +122,14 @@ export async function fetchPullRequestWorkItem( const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined try { const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + const inMergeQueue = + mergeMetadata.mergeQueueRequired === true + ? await readPullRequestInMergeQueue(ownerRepo, number, ghOptions) + : undefined return { ...mapped, mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(inMergeQueue !== undefined ? { inMergeQueue } : {}), ...(mergeMetadata.autoMergeAllowed !== null ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}), diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts index 2ee667ceacc4..aa06e73c154c 100644 --- a/src/main/github/client/lookup/pr-branch-lookup.ts +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -65,8 +65,9 @@ export async function hydrateBranchLookupWithExactPR( } try { return ( - (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? - branchData + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData, { + readQueueMembership: true + })) ?? branchData ) } catch { return branchData diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts index 42a605eb295c..55f14d74ab15 100644 --- a/src/main/github/client/lookup/pr-number-lookup.ts +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -52,7 +52,8 @@ export async function getPRByNumber( number: number, ghOptions: ReturnType, executionScope: string, - knownPullRequestData?: PullRequestLookupData | null + knownPullRequestData?: PullRequestLookupData | null, + options?: { readQueueMembership?: boolean } ): Promise { try { const { stdout } = await ghExecFileAsync( @@ -76,7 +77,8 @@ export async function getPRByNumber( ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) }, ghOptions, - executionScope + executionScope, + options ) } catch (err) { // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -89,7 +91,7 @@ export async function getPRByNumber( ? await getRestPRByNumber(ownerRepo, number, ghOptions) : knownPullRequestData return restData - ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope, options) : null } catch (restErr) { if (isNotFoundGhError(restErr)) { @@ -115,7 +117,9 @@ export async function lookupPRByNumber(args: { candidate, args.number, args.ghOptions, - args.executionScope + args.executionScope, + undefined, + { readQueueMembership: true } ) if (!linkedData) { continue diff --git a/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts b/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts index 1a494c407cc5..9a1ecd095404 100644 --- a/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts +++ b/src/main/github/client/lookup/pr-refresh-outcome-assembly.ts @@ -52,6 +52,7 @@ export function assemblePRRefreshFoundOutcome(args: { : data.mergeQueueRequired } : {}), + ...(data.inMergeQueue !== undefined ? { inMergeQueue: data.inMergeQueue } : {}), ...(data.mergeMethodSettings !== undefined ? { mergeMethodSettings: data.mergeMethodSettings } : {}), diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 14e3d92337eb..cc7174af0e63 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -31,6 +31,7 @@ export type PullRequestLookupData = { autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null + inMergeQueue?: boolean mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null baseRefName?: string diff --git a/src/main/github/client/lookup/pull-request-lookup-hydration.ts b/src/main/github/client/lookup/pull-request-lookup-hydration.ts index bf2993a0f6dd..b4b9720b9bc1 100644 --- a/src/main/github/client/lookup/pull-request-lookup-hydration.ts +++ b/src/main/github/client/lookup/pull-request-lookup-hydration.ts @@ -1,6 +1,7 @@ import type { OwnerRepo } from '../../gh-utils' import type { GhExecOptions } from './../github-exec-scope' import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' +import { readPullRequestInMergeQueue } from './pull-request-merge-queue-membership' import { normalizePullRequestLookupData, type PullRequestLookupData @@ -9,7 +10,8 @@ export async function hydratePullRequestLookupData( ownerRepo: OwnerRepo, data: PullRequestLookupData, ghOptions: GhExecOptions, - executionScope: string + executionScope: string, + options?: { readQueueMembership?: boolean } ): Promise { const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = @@ -22,12 +24,17 @@ export async function hydratePullRequestLookupData( executionScope ) : undefined + const inMergeQueue = + options?.readQueueMembership === true && mergeMetadata?.mergeQueueRequired === true + ? await readPullRequestInMergeQueue(ownerRepo, normalized.number, ghOptions) + : undefined return { ...normalized, ...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}), ...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}), ...(mergeMetadata?.mergeMethodSettings ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) + : {}), + ...(inMergeQueue !== undefined ? { inMergeQueue } : {}) } } diff --git a/src/main/github/client/lookup/pull-request-merge-queue-membership.ts b/src/main/github/client/lookup/pull-request-merge-queue-membership.ts new file mode 100644 index 000000000000..92e8cdeee105 --- /dev/null +++ b/src/main/github/client/lookup/pull-request-merge-queue-membership.ts @@ -0,0 +1,47 @@ +import { ghExecFileAsync } from '../../gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import type { GhExecOptions } from '../github-exec-scope' + +/** True when this pull request already has a merge-queue entry. + * `gh pr view` has no field for that, and enqueue does not set autoMergeRequest. */ +export async function readPullRequestInMergeQueue( + ownerRepo: GitHubApiRepository, + prNumber: number, + ghOptions: GhExecOptions +): Promise { + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + return undefined + } + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { isInMergeQueue } + } + }` + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `number=${prNumber}` + ], + { ...ghOptions, ...githubHostExecOptions(ownerRepo) } + ) + const parsed = JSON.parse(stdout) as { + data?: { repository?: { pullRequest?: { isInMergeQueue?: unknown } | null } | null } + } + const value = parsed.data?.repository?.pullRequest?.isInMergeQueue + return typeof value === 'boolean' ? value : undefined + } catch { + return undefined + } +} diff --git a/src/main/github/client/merge/pr-auto-merge.ts b/src/main/github/client/merge/pr-auto-merge.ts index 0cc65c5fb039..fb702b187eb4 100644 --- a/src/main/github/client/merge/pr-auto-merge.ts +++ b/src/main/github/client/merge/pr-auto-merge.ts @@ -67,14 +67,14 @@ export async function runPRAutoMergeCommand( }) } -export async function shouldUseMergeQueueAutoMerge( +async function mergeQueueEnqueueDecision( pr: PRAutoMergeIdentity, ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions, executionScope?: string -): Promise { +): Promise<'enqueue' | 'auto' | 'skip'> { if (!ownerRepo || !pr.baseRefName) { - return false + return 'skip' } const mergeMetadata = await detectRepositoryMergeMetadata( ownerRepo, @@ -82,7 +82,35 @@ export async function shouldUseMergeQueueAutoMerge( ghOptions, executionScope ) - return mergeMetadata.mergeQueueRequired === true + if (mergeMetadata.mergeQueueRequired !== true) { + return 'skip' + } + // A required queue that disallows auto-merge must be enqueued. The mutation + // does not set autoMergeRequest, so callers have to say it was queued. + return mergeMetadata.autoMergeAllowed === false ? 'enqueue' : 'auto' +} + +async function enqueuePullRequest( + pr: PRAutoMergeIdentity, + ghOptions: GhExecOptions +): Promise { + const query = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID) { + enqueuePullRequest(input: { + pullRequestId: $pullRequestId, + expectedHeadOid: $expectedHeadOid + }) { + mergeQueueEntry { id } + } + }` + const args = ['api', 'graphql', '-f', `query=${query}`, '-f', `pullRequestId=${pr.id}`] + if (pr.headRefOid) { + args.push('-f', `expectedHeadOid=${pr.headRefOid}`) + } + // Why: `gh pr merge --auto` calls enablePullRequestAutoMerge, which GitHub rejects when the repository disables auto-merge. + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) } export async function enablePRAutoMerge( @@ -91,7 +119,7 @@ export async function enablePRAutoMerge( ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions, executionScope?: string -): Promise<{ ok: true } | { ok: false; error: string }> { +): Promise<{ ok: true; enqueued?: true } | { ok: false; error: string }> { if (ownerRepo) { try { const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) @@ -109,8 +137,17 @@ export async function enablePRAutoMerge( if (!pr?.id) { return { ok: false, error: 'Could not resolve GitHub pull request ID' } } - const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions, executionScope) - if (useMergeQueue) { + const mergeQueueDecision = await mergeQueueEnqueueDecision( + pr, + ownerRepo, + ghOptions, + executionScope + ) + if (mergeQueueDecision === 'enqueue') { + await enqueuePullRequest(pr, ghOptions) + return { ok: true, enqueued: true } + } + if (mergeQueueDecision === 'auto') { await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) return { ok: true } } @@ -152,7 +189,7 @@ export async function setPRAutoMerge( connectionId?: string | null, prRepo?: GitHubApiRepository | null, localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { +): Promise<{ ok: true; enqueued?: true } | { ok: false; error: string }> { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, prRepo, diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts index d02c4b9a50e8..c6d1c375a115 100644 --- a/src/preload/api/gh-bridge-mutations-and-projects.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -50,7 +50,7 @@ export const ghMutationsAndProjectsApi = { enabled: boolean method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => + }): Promise<{ ok: true; enqueued?: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:setPRAutoMerge', args), updatePRState: (args: { repoPath: string diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts index 68de9ac2d317..4a1f643688f8 100644 --- a/src/preload/api/github-pull-request-api.ts +++ b/src/preload/api/github-pull-request-api.ts @@ -150,7 +150,7 @@ export type GithubPullRequestApi = { method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null } - ) => Promise<{ ok: true } | { ok: false; error: string }> + ) => Promise<{ ok: true; enqueued?: true } | { ok: false; error: string }> updatePRState: ( args: GitHubRepoSelectorArgs & { prNumber: number diff --git a/src/renderer/src/components/github-auto-merge-success-toast.ts b/src/renderer/src/components/github-auto-merge-success-toast.ts new file mode 100644 index 000000000000..61c5ded6b078 --- /dev/null +++ b/src/renderer/src/components/github-auto-merge-success-toast.ts @@ -0,0 +1,31 @@ +import { translate } from '@/i18n/i18n' +import type { GitHubPRMergeStatePresentation } from './github-pr-merge-state' + +export function githubAutoMergeSuccessToast( + enabled: boolean, + result: { enqueued?: boolean } | void | null +): string { + if (!enabled) { + return translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled') + } + if (result && result.enqueued === true) { + return translate( + 'auto.components.PullRequestPage.mergeQueueEnqueued', + 'Added to the merge queue' + ) + } + return translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled') +} + +export function presentPullRequestInMergeQueue(tone: string): GitHubPRMergeStatePresentation { + return { + label: translate('auto.components.github.pr.merge.state.inMergeQueue', 'In merge queue'), + tone, + tooltip: translate( + 'auto.components.github.pr.merge.state.inMergeQueueTooltip', + 'This pull request is already in the GitHub merge queue' + ), + directMergeAvailable: false, + autoMergeAction: null + } +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx index 487411e75e81..8da7077dce82 100644 --- a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx @@ -11,6 +11,7 @@ import { getGitHubMutationRoutingSettings, getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' +import { githubAutoMergeSuccessToast } from '@/components/github-auto-merge-success-toast' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -310,11 +311,7 @@ export function PRActionsPanel({ ) } useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - enabled - ? translate('auto.components.GitHubItemDialog.a35ea5a0f6', 'Auto-merge enabled') - : translate('auto.components.GitHubItemDialog.4b390bd50d', 'Auto-merge disabled') - ) + toast.success(githubAutoMergeSuccessToast(enabled, result)) onMutated() } catch { toast.error( diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index 036d34c4c0db..528d6be1c075 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { githubAutoMergeSuccessToast } from './github-auto-merge-success-toast' import { presentGitHubPRMergeState, type GitHubPRMergeStateInput } from './github-pr-merge-state' function pr(overrides: Partial = {}): GitHubPRMergeStateInput { @@ -24,6 +25,21 @@ describe('presentGitHubPRMergeState', () => { }) }) + it('says a disallowed merge queue was enqueued, not auto-merged', () => { + expect(githubAutoMergeSuccessToast(true, { enqueued: true })).toBe('Added to the merge queue') + expect(githubAutoMergeSuccessToast(true, {})).toBe('Auto-merge enabled') + }) + + it('hides enqueue once the pull request is already in the merge queue', () => { + expect( + presentGitHubPRMergeState(pr({ mergeQueueRequired: true, inMergeQueue: true })) + ).toMatchObject({ + label: 'In merge queue', + directMergeAvailable: false, + autoMergeAction: null + }) + }) + it('uses the merge-queue label for auto-merge when a queue is required', () => { expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: true }))).toMatchObject({ label: 'Merge when ready', diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index f26edd5896ba..66288965a944 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -7,6 +7,7 @@ import type { } from '../../../shared/github/pull-request-types' import { canEnableGitHubPRAutoMerge } from '../../../shared/github/pull-request-auto-merge-availability' import { translate } from '@/i18n/i18n' +import { presentPullRequestInMergeQueue } from '@/components/github-auto-merge-success-toast' export type GitHubPRMergeStateInput = { state: PRState | 'open' | 'closed' | 'merged' | 'draft' @@ -18,6 +19,7 @@ export type GitHubPRMergeStateInput = { autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null + inMergeQueue?: boolean } export type GitHubPRAutoMergeAction = { @@ -93,43 +95,45 @@ export function presentGitHubPRMergeState( const autoMergeAction = item.state !== 'open' ? null - : item.autoMergeEnabled === true - ? { - kind: 'disable' as const, - label: translate( - 'auto.components.github.pr.merge.state.48d75ae118', - 'Disable auto-merge' - ), - tooltip: translate( - 'auto.components.github.pr.merge.state.62703b1dc4', - 'GitHub auto-merge is enabled for this pull request' - ) - } - : item.mergeQueueRequired === true + : item.inMergeQueue === true + ? null + : item.autoMergeEnabled === true ? { - kind: 'enable' as const, + kind: 'disable' as const, label: translate( - 'auto.components.github.pr.merge.state.b169f943e1', - 'Merge when ready' + 'auto.components.github.pr.merge.state.48d75ae118', + 'Disable auto-merge' ), tooltip: translate( - 'auto.components.github.pr.merge.state.331ebe1170', - 'Add this pull request to the GitHub merge queue' + 'auto.components.github.pr.merge.state.62703b1dc4', + 'GitHub auto-merge is enabled for this pull request' ) } - : canEnableAutoMerge(item) + : item.mergeQueueRequired === true ? { kind: 'enable' as const, label: translate( - 'auto.components.github.pr.merge.state.4ab19a62ef', - 'Enable auto-merge' + 'auto.components.github.pr.merge.state.b169f943e1', + 'Merge when ready' ), tooltip: translate( - 'auto.components.github.pr.merge.state.8f6cb3772f', - 'Merge this pull request automatically once requirements are met' + 'auto.components.github.pr.merge.state.331ebe1170', + 'Add this pull request to the GitHub merge queue' ) } - : null + : canEnableAutoMerge(item) + ? { + kind: 'enable' as const, + label: translate( + 'auto.components.github.pr.merge.state.4ab19a62ef', + 'Enable auto-merge' + ), + tooltip: translate( + 'auto.components.github.pr.merge.state.8f6cb3772f', + 'Merge this pull request automatically once requirements are met' + ) + } + : null if (item.state === 'merged') { return { @@ -191,6 +195,9 @@ export function presentGitHubPRMergeState( autoMergeAction } } + if (item.inMergeQueue === true) { + return presentPullRequestInMergeQueue(WARNING_TONE) + } if (item.mergeQueueRequired === true) { return { label: item.autoMergeEnabled ? 'Auto-merge on' : 'Merge when ready', diff --git a/src/renderer/src/components/pull-request-page/actions/merge-actions.ts b/src/renderer/src/components/pull-request-page/actions/merge-actions.ts index 27bd98d963c1..19dcca94441a 100644 --- a/src/renderer/src/components/pull-request-page/actions/merge-actions.ts +++ b/src/renderer/src/components/pull-request-page/actions/merge-actions.ts @@ -14,6 +14,7 @@ import type { GitHubWorkItem } from '../../../../../shared/github/work-item-type import type { TaskSourceContext } from '../../../../../shared/task-source-context' import type { PullRequestPageProjectOrigin } from '../page-types' import { translate } from '@/i18n/i18n' +import { githubAutoMergeSuccessToast } from '@/components/github-auto-merge-success-toast' import type { GitHubPRMergeStatePresentation } from '@/components/github-pr-merge-state' export async function changePullRequestState(args: { @@ -252,11 +253,7 @@ export async function setPullRequestAutoMerge(args: { { local: false } ) } - toast.success( - enabled - ? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled') - : translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled') - ) + toast.success(githubAutoMergeSuccessToast(enabled, result)) args.onMutated() } catch (err) { toast.error( diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts b/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts index 8a6933816284..115714812b7e 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts @@ -55,6 +55,9 @@ function applyServerEntityIfPresent( opts.serverEntity.autoMergeEnabled ) } + if (opts.serverEntity.inMergeQueue !== undefined) { + entityPatch.inMergeQueue = opts.serverEntity.inMergeQueue + } if (opts.serverEntity.assignees) { const users = freezeTaskPageGitHubUsers(opts.serverEntity.assignees) setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, 'assignees', users) diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts b/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts index a24a20e33b63..ef772026551c 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts @@ -370,4 +370,30 @@ describe('TaskPage GitHub mutation regressions', () => { expect(patches.findLast((patch) => patch.state !== undefined)?.state).toBe('closed') expect(patches.at(-1)?.assignees).toBeUndefined() }) + + it('confirms an enqueue as queued rather than auto-merge enabled', () => { + const patches: Partial[] = [] + const patchWorkItem = (_id: string, patch: Partial): void => { + patches.push(patch) + } + const base = item({ type: 'pr', autoMergeEnabled: false, inMergeQueue: false }) + const began = beginTaskPageGitHubWorkItemMutation({ + item: base, + intent: { type: 'setAutoMerge', enabled: true }, + query: query(), + queryKey: 'q', + viewerLogin: 'me', + patchWorkItem + }) + confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, { + query: query(), + queryKey: 'q', + viewerLogin: 'me', + item: base, + patchWorkItem, + serverEntity: { autoMergeEnabled: false, inMergeQueue: true } + }) + expect(patches.at(-1)).toMatchObject({ autoMergeEnabled: false, inMergeQueue: true }) + expect(getLastConfirmedClientValue(null, base.repoId, base.id, 'autoMerge')).toBe(false) + }) }) diff --git a/src/renderer/src/components/task-page-linear-jira-list-model.tsx b/src/renderer/src/components/task-page-linear-jira-list-model.tsx index 42cff688a42c..0c71669b6452 100644 --- a/src/renderer/src/components/task-page-linear-jira-list-model.tsx +++ b/src/renderer/src/components/task-page-linear-jira-list-model.tsx @@ -184,6 +184,8 @@ export type TaskPageGitHubWorkItemMutationRunner = { } } | void> successToast?: string + successToastFromResult?: (result: { enqueued?: boolean } | void) => string + serverEntityFromResult?: (result: unknown) => Partial | undefined errorToast: string }) => Promise<'confirmed' | 'rolled_back' | 'stale'> isIntentPending: (input: { diff --git a/src/renderer/src/components/task-page-work-item-signatures.ts b/src/renderer/src/components/task-page-work-item-signatures.ts index 8abb2f07a192..73e1f5be0cdb 100644 --- a/src/renderer/src/components/task-page-work-item-signatures.ts +++ b/src/renderer/src/components/task-page-work-item-signatures.ts @@ -38,6 +38,7 @@ export function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string { item.autoMergeEnabled ?? null, item.autoMergeAllowed ?? null, item.mergeQueueRequired ?? null, + item.inMergeQueue ?? null, item.mergeStateStatus ?? null, item.updatedAt ]) diff --git a/src/renderer/src/components/task-page/github/MergeCell.tsx b/src/renderer/src/components/task-page/github/MergeCell.tsx index 1213c8062a72..508127394079 100644 --- a/src/renderer/src/components/task-page/github/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/MergeCell.tsx @@ -11,6 +11,7 @@ import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { translate } from '@/i18n/i18n' +import { githubAutoMergeSuccessToast } from '@/components/github-auto-merge-success-toast' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { resolveGitHubPRMergeMethods, @@ -165,9 +166,14 @@ export function PRMergeCell({ enabled }, sourceContext, - successToast: enabled - ? translate('auto.components.TaskPage.fed317634c', 'Auto-merge enabled') - : translate('auto.components.TaskPage.a5bf86defe', 'Auto-merge disabled'), + successToastFromResult: (result) => githubAutoMergeSuccessToast(enabled, result), + serverEntityFromResult: (result) => { + const typed = result as { enqueued?: boolean } | void + if (!typed || typed.enqueued !== true) { + return undefined + } + return { autoMergeEnabled: false, inMergeQueue: true } + }, errorToast: enabled ? translate('auto.components.TaskPage.a3318684bc', 'Failed to enable auto-merge') : translate('auto.components.TaskPage.1a9ea003dc', 'Failed to disable auto-merge'), diff --git a/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts b/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts index 24b6a969c68d..96c01f1923c8 100644 --- a/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts +++ b/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts @@ -49,8 +49,13 @@ export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkIte item: GitHubWorkItem intent: TaskPageGitHubMutationIntent sourceContext?: TaskSourceContext | null - mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void> + mutate: () => Promise<{ + ok?: boolean + error?: string | { message?: string } + enqueued?: boolean + } | void> successToast?: string + successToastFromResult?: (result: { enqueued?: boolean } | void) => string errorToast: string serverEntityFromResult?: (result: unknown) => Partial | undefined }) => Promise<'confirmed' | 'rolled_back' | 'stale'> @@ -104,8 +109,13 @@ export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkIte item: GitHubWorkItem intent: TaskPageGitHubMutationIntent sourceContext?: TaskSourceContext | null - mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void> + mutate: () => Promise<{ + ok?: boolean + error?: string | { message?: string } + enqueued?: boolean + } | void> successToast?: string + successToastFromResult?: (result: { enqueued?: boolean } | void) => string errorToast: string serverEntityFromResult?: (result: unknown) => Partial | undefined }): Promise<'confirmed' | 'rolled_back' | 'stale'> => { @@ -127,7 +137,11 @@ export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkIte try { const result = await input.mutate() const activeQuery = activeQueryRef.current - const typed = result as { ok?: boolean; error?: string | { message?: string } } | void + const typed = result as { + ok?: boolean + error?: string | { message?: string } + enqueued?: boolean + } | void if (typed && typeof typed === 'object' && typed.ok === false) { const rolled = rollbackTaskPageGitHubWorkItemMutation({ key: began.key, @@ -162,8 +176,9 @@ export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkIte scheduleQuiet: false }) if (confirmed === 'confirmed') { - if (input.successToast && mountedRef.current) { - toast.success(input.successToast) + const successToast = input.successToastFromResult?.(typed) ?? input.successToast + if (successToast && mountedRef.current) { + toast.success(successToast) } useAppStore.getState().recordFeatureInteraction('github-tasks') } diff --git a/src/shared/github/pull-request-types.ts b/src/shared/github/pull-request-types.ts index 6024d702bf3d..25ce0ed067ad 100644 --- a/src/shared/github/pull-request-types.ts +++ b/src/shared/github/pull-request-types.ts @@ -60,6 +60,8 @@ export type PRInfo = { autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null + /** True once GitHub has a merge-queue entry. Enqueue does not set autoMergeRequest. */ + inMergeQueue?: boolean mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ diff --git a/src/shared/github/work-item-types.ts b/src/shared/github/work-item-types.ts index 437a1af20f05..740f93a4e442 100644 --- a/src/shared/github/work-item-types.ts +++ b/src/shared/github/work-item-types.ts @@ -46,6 +46,7 @@ export type GitHubWorkItem = { autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null + inMergeQueue?: boolean mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null maintainerCanModify?: boolean diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index 26d866d5c09a..32eba5571c73 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -15,6 +15,7 @@ export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo { ...(pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: pr.autoMergeEnabled } : {}), ...(pr.autoMergeAllowed !== undefined ? { autoMergeAllowed: pr.autoMergeAllowed } : {}), ...(pr.mergeQueueRequired !== undefined ? { mergeQueueRequired: pr.mergeQueueRequired } : {}), + ...(pr.inMergeQueue !== undefined ? { inMergeQueue: pr.inMergeQueue } : {}), ...(pr.mergeStateStatus !== undefined ? { mergeStateStatus: pr.mergeStateStatus } : {}), ...(pr.headSha ? { headSha: pr.headSha } : {}), ...(pr.prRepo ? { githubRepository: pr.prRepo } : {}), diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 719c6d27fa53..541f94b3e3e8 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -40,6 +40,7 @@ export type HostedReviewInfo = { autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null + inMergeQueue?: boolean mergeStateStatus?: string | null headSha?: string /** GitHub repository that owns the PR; absent on older runtimes and other providers. */