From be9a5231e104c10f04cfd0264ddb87afbd922fe5 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:46:28 +0200 Subject: [PATCH 01/10] Agents - limit creating a session from a pull request in the same repository, but ensure that the worktree is set up correctly (#331962) * Agents - limit creating a session from a pull request in the same repository, but ensure that the worktree is set up correctly * Remove tests related to code paths that were removed --- .../agentHost/common/sessionConfigKeys.ts | 2 + .../node/agentHostChangesetService.ts | 3 +- .../node/agentHostGitStateService.ts | 6 +- .../platform/agentHost/node/agentService.ts | 13 ++- .../node/shared/worktreeIsolation.ts | 101 +++++++++++------ .../agentHostGitService.integrationTest.ts | 62 ++++++++++ .../node/agentHostGitStateService.test.ts | 20 +++- .../agentHost/test/node/agentService.test.ts | 107 +++++++++++++++++- .../node/shared/worktreeIsolation.test.ts | 59 ++++++++-- .../createSessionFromPullRequestAction.ts | 7 +- .../fetchers/githubPullRequestsFetcher.ts | 4 + .../github/browser/pullRequestPicker.ts | 6 +- .../sessions/contrib/github/common/types.ts | 1 + .../test/browser/githubFetchers.test.ts | 3 + .../test/browser/pullRequestPicker.test.ts | 19 +++- .../browser/agentHostSessionConfigPicker.ts | 7 +- .../browser/baseAgentHostSessionsProvider.ts | 7 ++ .../agentHostSessionConfigPicker.test.ts | 10 +- .../localAgentHostSessionsProvider.test.ts | 4 + .../browser/sessionsManagementService.ts | 7 +- .../sessions/common/sessionsManagement.ts | 4 + .../sessions/common/sessionsProvider.ts | 4 + .../browser/sessionsManagementService.test.ts | 6 +- 23 files changed, 402 insertions(+), 60 deletions(-) diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index e11babc4d40..b7373c4c2c2 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -33,6 +33,8 @@ export const enum SessionConfigKey { WorktreeIncludeFiles = 'worktreeIncludeFiles', /** `'worktreeBranchTrack'` — host-owned branch tracking preference for programmatic session creation. */ WorktreeBranchTrack = 'worktreeBranchTrack', + /** `'worktreeCreateNewBranch'` — host-owned choice to create a branch instead of checking out the selected branch. */ + WorktreeCreateNewBranch = 'worktreeCreateNewBranch', /** `'agentMerge'` — client-owned Agent Merge enablement and session overrides. */ AgentMerge = 'agentMerge', /** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */ diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index e29b9d17b14..b875b508f47 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -215,7 +215,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private _hasWorkingDirectory(session: ProtocolURI): boolean { - return !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0]; + return !this._configurationService.isWorkingDirectoryPending(session) + && !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0]; } registerStaticChangesets(session: ProtocolURI): void { diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index fff3b2b5eda..94f7b374053 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -338,8 +338,10 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi async resolveSessionBaseBranchName(sessionKey: string): Promise { const state = this._stateManager.getSessionState(sessionKey); - const configuredBranch = state?.config?.values[SessionConfigKey.Isolation] === 'worktree' - ? state.config.values[SessionConfigKey.Branch] + const configValues = state?.config?.values; + const configuredBranch = configValues?.[SessionConfigKey.Isolation] === 'worktree' + && configValues[SessionConfigKey.WorktreeCreateNewBranch] !== false + ? configValues[SessionConfigKey.Branch] : undefined; if (typeof configuredBranch === 'string' && configuredBranch.trim()) { return resolveDiffBaseBranchName(configuredBranch.trim(), undefined); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 241ae294e7b..4b5c72a560c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -140,6 +140,7 @@ const HOST_OWNED_SESSION_CONFIG_KEYS = [ SessionConfigKey.WorktreeBranchPrefix, SessionConfigKey.WorktreeIncludeFiles, SessionConfigKey.WorktreeBranchTrack, + SessionConfigKey.WorktreeCreateNewBranch, ] as const; /** @@ -2689,8 +2690,10 @@ export class AgentService extends Disposable implements IAgentService { } } - const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; - void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); + if (!this._configurationService.isWorkingDirectoryPending(session.toString())) { + const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; + void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); + } return session; } @@ -3643,6 +3646,9 @@ export class AgentService extends Disposable implements IAgentService { if (iso.worktreeBranchTrackProperty) { properties[SessionConfigKey.WorktreeBranchTrack] = iso.worktreeBranchTrackProperty.protocol; } + if (iso.worktreeCreateNewBranchProperty) { + properties[SessionConfigKey.WorktreeCreateNewBranch] = iso.worktreeCreateNewBranchProperty.protocol; + } if (iso.worktreeIncludeFilesProperty) { properties[SessionConfigKey.WorktreeIncludeFiles] = iso.worktreeIncludeFilesProperty.protocol; } @@ -3657,6 +3663,9 @@ export class AgentService extends Disposable implements IAgentService { if (iso.worktreeBranchTrackProperty && typeof params.config?.[SessionConfigKey.WorktreeBranchTrack] === 'boolean') { values[SessionConfigKey.WorktreeBranchTrack] = params.config[SessionConfigKey.WorktreeBranchTrack]; } + if (iso.worktreeCreateNewBranchProperty && typeof params.config?.[SessionConfigKey.WorktreeCreateNewBranch] === 'boolean') { + values[SessionConfigKey.WorktreeCreateNewBranch] = params.config[SessionConfigKey.WorktreeCreateNewBranch]; + } if (iso.worktreeIncludeFilesProperty && Array.isArray(params.config?.[SessionConfigKey.WorktreeIncludeFiles]) && params.config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')) { diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 414bed17323..2e1c9aef4a0 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -293,6 +293,8 @@ export interface IIsolationConfigContribution { readonly worktreeIncludeFilesProperty: ISchemaProperty | undefined; /** Read-only carrier for the programmatic worktree branch tracking preference. */ readonly worktreeBranchTrackProperty: ISchemaProperty | undefined; + /** Read-only carrier for checking out the selected branch directly. */ + readonly worktreeCreateNewBranchProperty: ISchemaProperty | undefined; readonly isolationValue: 'folder' | 'worktree'; readonly branchDefault: string | undefined; readonly branchValue: string | undefined; @@ -475,6 +477,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let worktreeBranchPrefixProperty: ISchemaProperty | undefined; let worktreeIncludeFilesProperty: ISchemaProperty | undefined; let worktreeBranchTrackProperty: ISchemaProperty | undefined; + let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch; @@ -520,6 +523,15 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI sessionMutable: false, }); + worktreeCreateNewBranchProperty = schemaProperty({ + type: 'boolean', + title: localize('agentHost.sessionConfig.worktreeCreateNewBranch', "Create New Worktree Branch"), + description: localize('agentHost.sessionConfig.worktreeCreateNewBranchDescription', "Whether to create a new branch for the isolated worktree."), + default: true, + readOnly: true, + sessionMutable: false, + }); + worktreeIncludeFilesProperty = schemaProperty({ type: 'array', title: localize('agentHost.sessionConfig.worktreeIncludeFiles', "Worktree Include Files"), @@ -533,7 +545,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI }); } - return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; + return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; } /** @@ -563,7 +575,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI /** * Resolves the effective working directory for a session that is about to * be materialized. When the session config selects `worktree` isolation on - * a git repository, creates a fresh branch + worktree, records it for + * a git repository, creates or checks out a branch in a worktree, records it for * cleanup, queues the first-turn announcement, persists the worktree * metadata, and returns the worktree URI. Otherwise returns the requested * working directory unchanged. @@ -591,50 +603,69 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } const repositoryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, checkoutRoot); - const worktreesRoot = getWorktreesRoot(repositoryRoot); + + const selectedBranch = config[SessionConfigKey.Branch] as string; + const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; + const worktreeCreateNewBranch = config[SessionConfigKey.WorktreeCreateNewBranch] !== false; + // Prefix (e.g. the user's `git.branchPrefix`) the client forwards for // worktree-isolated sessions. Prepended ahead of the built-in `agents/` // prefix when naming the branch and stripped from the worktree dir name. - const worktreeBranchPrefix = typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string' + const worktreeBranchPrefix = worktreeCreateNewBranch && typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string' ? config[SessionConfigKey.WorktreeBranchPrefix] as string : undefined; - const selectedBranch = config[SessionConfigKey.Branch] as string; - const { branchName, worktree, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => { - onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch)); - const branchName = await this._branchNameGenerator.generateBranchName({ - sessionId, - message: prompt, - githubToken, - branchPrefix: worktreeBranchPrefix, - branchNameCollides: async candidate => { - if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) { - return true; - } - const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix)); - return fileExists(candidateWorktree.fsPath); - }, - }); - const worktree = URI.joinPath(worktreesRoot, getWorktreeName(branchName, worktreeBranchPrefix)); - const baseBranch = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); + + const { worktreePath, branchName, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => { + const worktreesRoot = getWorktreesRoot(repositoryRoot); + + if (worktreeCreateNewBranch) { + onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch)); + } + const newBranchName = worktreeCreateNewBranch + ? await this._branchNameGenerator.generateBranchName({ + sessionId, + message: prompt, + githubToken, + branchPrefix: worktreeBranchPrefix, + branchNameCollides: async candidate => { + if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) { + return true; + } + const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix)); + return fileExists(candidateWorktree.fsPath); + }, + }) + : undefined; + + const branchStartPoint = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch); + + const baseBranch = worktreeCreateNewBranch + ? branchStartPoint + : (await this._gitService.getDefaultBranch(repositoryRoot))?.startPoint; // Git suppresses progress for the first couple of seconds, so name // the phase up front rather than leaving the label stale until the // first percentage arrives. onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); - const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; + await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); + const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix)); + await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => this._gitService.addWorktree(repositoryRoot, { - path: worktree, - commitish: baseBranch, - newBranchName: branchName, + path: worktreePath, + commitish: worktreeCreateNewBranch + ? branchStartPoint + : selectedBranch, + newBranchName, + preferRemoteBranch: worktreeCreateNewBranch, track: worktreeBranchTrack, - preferRemoteBranch: true, onProgress: progress, })); - return { branchName, worktree, baseBranch }; + + return { branchName: newBranchName ?? selectedBranch, worktreePath, baseBranch }; }); + const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) && config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string') ? config[SessionConfigKey.WorktreeIncludeFiles] as readonly string[] @@ -643,21 +674,25 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI try { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles)); await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress => - this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktree, worktreeIncludeFiles, progress)); + this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, progress)); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`); } } - this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree }); + + this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); + // Queue the worktree announcement so the first turn (live) and any // subsequent restore (history) both surface the message in the chat. this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); + try { - await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot }); + await this._writeWorktreeMetadata(sessionUri, { repositoryRoot, worktreePath, baseBranch, branchName }); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to persist worktree branch metadata: ${errorMessage(error)}`); } - return worktree; + + return worktreePath; } /** Resolves a persisted working directory, repairing a removed worktree when possible. */ diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index a74bd772cfd..59e92517d4e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -764,6 +764,68 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { } }); + (hasGit ? test : test.skip)('addWorktree preserves tracking when attaching an existing branch', async () => { + const dir = initRepo(); + const remotePath = join(dir, 'remote.git'); + cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', 'origin', 'main'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', '--set-upstream', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + track: true, + }); + + assert.deepStrictEqual({ + branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + }, { + branch: 'feature', + upstream: 'origin/feature', + }); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + + (hasGit ? test : test.skip)('addWorktree automatically tracks a remote branch when creating its local branch', async () => { + const dir = initRepo(); + const remotePath = join(dir, 'remote.git'); + cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', 'main'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', '-D', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + newBranchName: 'feature', + track: true, + preferRemoteBranch: true, + }); + + assert.deepStrictEqual({ + branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + }, { + branch: 'feature', + upstream: 'origin/feature', + }); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + (hasGit ? test : test.skip)('removeWorktree preserves dirty work unless forced', async () => { const dir = initRepo(); const fs = await import('fs/promises'); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 84fed362bde..0e46fd32959 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -210,7 +210,7 @@ suite('AgentHostGitStateService', () => { }; } - function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createdAt?: number }): void { + function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createNewBranch?: boolean; createdAt?: number }): void { const summary: SessionSummary = { resource: SESSION, provider: 'mock', @@ -230,6 +230,7 @@ suite('AgentHostGitStateService', () => { values: { [SessionConfigKey.Isolation]: options.isolation, ...(options.baseBranch ? { [SessionConfigKey.Branch]: options.baseBranch } : {}), + ...(options.createNewBranch !== undefined ? { [SessionConfigKey.WorktreeCreateNewBranch]: options.createNewBranch } : {}), }, }); } @@ -314,6 +315,23 @@ suite('AgentHostGitStateService', () => { assert.deepStrictEqual(h.gitBaseBranches, ['release']); })); + test('uses the persisted base branch when the selected branch is checked out directly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { + workingDirectory: WORKING_DIRECTORY, + project: 'file:///repo', + isolation: 'worktree', + baseBranch: 'feature/pr', + createNewBranch: false, + }); + await h.db.setMetadata(META_DIFF_BASE_BRANCH, 'origin/main'); + h.setGitResult({ branchName: 'feature/pr', baseBranchName: 'main' }); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual(h.gitBaseBranches, ['main']); + })); + test('uses the persisted worktree base branch for an adopted linked worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const h = createHarness(); seedSession(h.stateManager, { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index bd3d854d6ea..9b5136b6f4b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -36,6 +36,7 @@ import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js' import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; +import { GitRefType } from '../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -783,6 +784,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], [SessionConfigKey.WorktreeBranchTrack]: false, + [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'selected', }, }); @@ -800,6 +802,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], [SessionConfigKey.WorktreeBranchTrack]: false, + [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'completion', }, property: 'providerSetting', @@ -822,6 +825,7 @@ suite('AgentService (node dispatcher)', () => { branchPrefix: selected.values[SessionConfigKey.WorktreeBranchPrefix], includeFiles: selected.values[SessionConfigKey.WorktreeIncludeFiles], branchTrack: selected.values[SessionConfigKey.WorktreeBranchTrack], + createNewBranch: selected.values[SessionConfigKey.WorktreeCreateNewBranch], providerSetting: selected.values.providerSetting, }, folder: { @@ -844,7 +848,7 @@ suite('AgentService (node dispatcher)', () => { agentMergeController: { lastPromptFingerprint: 'fingerprint' }, providerSetting: 'initial', }, - selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, providerSetting: 'selected' }, + selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' }, }); }); @@ -12341,6 +12345,107 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); + test('pending worktree session defers git state and branch changes until materialization', async () => { + class ProvisionalWorktreeAgent extends MockAgent { + private readonly _onDidMaterializeChat = new Emitter(); + override readonly onDidMaterializeChat = this._onDidMaterializeChat.event; + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: (chat, context, options) => createProvisionalChat(base, chat, context, options), + })); + + materialize(session: URI, workingDirectory: URI): void { + this._onDidMaterializeChat.fire({ + chat: URI.parse(buildDefaultChatUri(session)), + workingDirectories: [workingDirectory], + project: undefined, + }); + } + + override dispose(): void { + this._onDidMaterializeChat.dispose(); + super.dispose(); + } + } + + const sourceDir = URI.file('/source/repo'); + const worktreeDir = URI.file('/source/repo.worktrees/feature'); + const gitStateCalls: Array<{ resource: string; baseBranch: string | undefined }> = []; + const diffCalls: string[] = []; + const gitService = createNoopGitService(); + gitService.getRepositoryRoot = async () => sourceDir; + gitService.revParse = async () => 'head'; + gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + gitService.getCurrentBranch = async () => 'main'; + gitService.getBranches = async () => [{ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }]; + gitService.getSessionGitState = async (resource, baseBranch) => { + gitStateCalls.push({ resource: resource.toString(), baseBranch }); + return { branchName: 'feature', baseBranchName: 'main' }; + }; + gitService.computeSessionFileDiffs = async resource => { + diffCalls.push(resource.toString()); + return []; + }; + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const isolation = disposables.add(new WorktreeIsolation( + { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + gitService, + new TestCopilotApiService(), + nullSessionDataService, + new NullLogService(), + )); + localService.setWorktreeIsolation(isolation); + const agent = new ProvisionalWorktreeAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + + const session = await localService.createSession({ + provider: agent.id, + workingDirectories: [sourceDir], + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'feature', + [SessionConfigKey.WorktreeCreateNewBranch]: false, + }, + }); + const branchChangeset = buildBranchChangesetUri(session.toString()); + localService.addSubscriber(URI.parse(branchChangeset), 'client-1'); + await timeout(0); + + const beforeMaterialization = { + workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + gitStateCalls: [...gitStateCalls], + diffCalls: [...diffCalls], + }; + + isolation.clearPending(AgentSession.id(session)); + agent.materialize(session, worktreeDir); + for (let i = 0; i < 20 && (gitStateCalls.length === 0 || diffCalls.length === 0); i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + beforeMaterialization, + afterMaterialization: { + workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + gitStateCalls, + diffCalls: [...new Set(diffCalls)], + }, + }, { + beforeMaterialization: { + workingDirectory: sourceDir.toString(), + gitStateCalls: [], + diffCalls: [], + }, + afterMaterialization: { + workingDirectory: worktreeDir.toString(), + gitStateCalls: [{ resource: worktreeDir.toString(), baseBranch: undefined }], + diffCalls: [worktreeDir.toString()], + }, + }); + localService.unsubscribe(URI.parse(branchChangeset), 'client-1'); + }); + test('_resolveWorkingDirectoryBeforeSend returns the full set (index 0 + tail), or undefined when unset', async () => { const resolver = service as unknown as { _resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise; diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index f4e59d44249..3ec01b3e73c 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -152,17 +152,17 @@ suite('WorktreeIsolation', () => { const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); assert.deepStrictEqual({ - noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty }, - repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly }, + noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, + repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum }, - repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty }, - noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty }, + repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, }, { - noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined }, - repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true }, + noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, + repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] }, - repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true }, - noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined }, + repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, }); }); @@ -205,6 +205,49 @@ suite('WorktreeIsolation', () => { }); }); + test('checks out an existing selected branch and uses the default branch as the diff base', async () => { + const gitService = createGitService(); + gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + const isolation = createIsolation(disposables, { + gitService, + branchNameGenerator: { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + }); + + const worktree = await isolation.resolveWorkingDirectory({ + sessionUri, + sessionId, + workingDirectory: repoRoot, + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'feature', + [SessionConfigKey.WorktreeBranchTrack]: true, + [SessionConfigKey.WorktreeCreateNewBranch]: false, + }, + }); + + assert.deepStrictEqual({ + worktree: worktree?.toString(), + addWorktreeArgs: addWorktreeCalls.map(call => ({ + commitish: call.commitish, + newBranchName: call.newBranchName, + track: call.track, + preferRemoteBranch: call.preferRemoteBranch, + })), + branchName: await db.getMetadata('copilot.worktree.branchName'), + diffBaseBranch: await db.getMetadata('agentHost.diffBaseBranch'), + }, { + worktree: URI.joinPath(worktreesRoot, 'feature').toString(), + addWorktreeArgs: [{ + commitish: 'feature', + newBranchName: undefined, + track: true, + preferRemoteBranch: false, + }], + branchName: 'feature', + diffBaseBranch: 'origin/main', + }); + }); + test('resolveWorkingDirectory creates a worktree, persists metadata, queues the announcement, and is idempotent', async () => { const isolation = createIsolation(disposables); const config = { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }; diff --git a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts index c12765fbedb..b595f42bc3d 100644 --- a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts +++ b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts @@ -27,7 +27,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionSection, SessionSectionHasGitHubRepositoryContext, SessionSectionHasNonCloudRepositoryContext, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; import { IGitHubService } from './githubService.js'; import { IGitHubPullRequestSummary } from '../common/types.js'; -import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; +import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, IPullRequestQuickPickItem, isPullRequestAvailable, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; import { createAndOpenPullRequestSession } from './pullRequestSessionCreation.js'; export const NEW_SESSION_FROM_PULL_REQUEST_COMMAND_ID = 'workbench.agentSessions.newSessionFromPullRequest'; @@ -192,7 +192,7 @@ registerAction2(class NewSessionFromPullRequestAction extends Action2 { const initialGroupsPromise = loadInitialGroups(); const loadUntilMatch = async (query: string, generation: number): Promise => { await initialGroupsPromise; - while (generation === searchGeneration && query && hasNextPage && !pullRequests.some(pullRequest => !hasExistingPullRequest(pullRequest, existingPullRequests) && pullRequestMatchesQuery(pullRequest, query))) { + while (generation === searchGeneration && query && hasNextPage && !pullRequests.some(pullRequest => isPullRequestAvailable(pullRequest, existingPullRequests) && pullRequestMatchesQuery(pullRequest, query))) { await loadNextPage(); } }; @@ -231,8 +231,9 @@ registerAction2(class NewSessionFromPullRequestAction extends Action2 { }, }, { isolationMode: 'worktree', - branch: pullRequest.checkoutRef, + branch: pullRequest.headRef, worktreeBranchTrack: true, + worktreeCreateNewBranch: false, metadata: createPullRequestSessionMetadata(repository.owner, repository.repo, pullRequest), onSessionCreated, }, pickerCts.token), diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPullRequestsFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPullRequestsFetcher.ts index 06890bbcc18..8db95cbe198 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPullRequestsFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPullRequestsFetcher.ts @@ -23,6 +23,7 @@ interface IGitHubPullRequestNode { readonly title: string; readonly author: { readonly login: string; readonly avatarUrl: string } | null; readonly headRefName: string; + readonly isCrossRepository: boolean; readonly isDraft: boolean; readonly updatedAt: string; readonly additions: number; @@ -42,6 +43,7 @@ const LIST_PULL_REQUESTS_QUERY = [ ' title', ' author { login avatarUrl }', ' headRefName', + ' isCrossRepository', ' isDraft', ' updatedAt', ' additions', @@ -62,6 +64,7 @@ const LIST_PULL_REQUEST_NUMBERS_QUERY = [ ' title', ' author { login avatarUrl }', ' headRefName', + ' isCrossRepository', ' isDraft', ' updatedAt', ' additions', @@ -123,6 +126,7 @@ function mapPullRequest(pullRequest: IGitHubPullRequestNode, reviewRequestedFrom author: pullRequest.author ?? { login: 'ghost', avatarUrl: '' }, headRef: pullRequest.headRefName, checkoutRef: `refs/pull/${pullRequest.number}/head`, + isCrossRepository: pullRequest.isCrossRepository, isDraft: pullRequest.isDraft, updatedAt: pullRequest.updatedAt, additions: pullRequest.additions, diff --git a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts index 816309c36a0..0c8ef2f9ff4 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts @@ -111,8 +111,12 @@ export function hasExistingPullRequest(pullRequest: IGitHubPullRequestSummary, e return existingPullRequests.numbers.has(pullRequest.number) || existingPullRequests.headRefs.has(pullRequest.headRef); } +export function isPullRequestAvailable(pullRequest: IGitHubPullRequestSummary, existingPullRequests: IExistingPullRequests): boolean { + return !pullRequest.isCrossRepository && !hasExistingPullRequest(pullRequest, existingPullRequests); +} + export function createPullRequestQuickPickItems(pullRequests: readonly IGitHubPullRequestSummary[], existingPullRequests: IExistingPullRequests): readonly (IPullRequestQuickPickItem | IQuickPickSeparator)[] { - const available = pullRequests.filter(pullRequest => !hasExistingPullRequest(pullRequest, existingPullRequests)); + const available = pullRequests.filter(pullRequest => isPullRequestAvailable(pullRequest, existingPullRequests)); const waitingForReview = available.filter(pullRequest => pullRequest.reviewRequestedFromViewer); const assigned = available.filter(pullRequest => !pullRequest.reviewRequestedFromViewer && pullRequest.assignedToViewer); const other = available.filter(pullRequest => !pullRequest.reviewRequestedFromViewer && !pullRequest.assignedToViewer); diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 4eb65c42589..49450508949 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -80,6 +80,7 @@ export interface IGitHubPullRequestSummary { readonly author: IGitHubUser; readonly headRef: string; readonly checkoutRef: string; + readonly isCrossRepository: boolean; readonly isDraft: boolean; readonly updatedAt: string; readonly additions: number; diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts index 288140f26e7..dabdf8f3d7d 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -450,6 +450,7 @@ suite('GitHubPullRequestsFetcher', () => { title: 'Improve sessions', author: { login: 'author', avatarUrl: 'avatar' }, headRefName: 'feature', + isCrossRepository: false, isDraft: true, updatedAt: '2026-07-30T12:00:00Z', additions: 12, @@ -470,6 +471,7 @@ suite('GitHubPullRequestsFetcher', () => { author: { login: 'author', avatarUrl: 'avatar' }, headRef: 'feature', checkoutRef: 'refs/pull/7/head', + isCrossRepository: false, isDraft: true, updatedAt: '2026-07-30T12:00:00Z', additions: 12, @@ -657,6 +659,7 @@ function makePullRequestSearchNode(number: number): unknown { title: `Pull request ${number}`, author: { login: 'author', avatarUrl: '' }, headRefName: `feature-${number}`, + isCrossRepository: false, isDraft: false, updatedAt: '2026-07-30T12:00:00Z', additions: number, diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts index eaef8075e93..a50e5c72305 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts @@ -13,7 +13,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { readSessionGitHubState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; -import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js'; +import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, isPullRequestAvailable, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js'; import { IGitHubPullRequestSummary } from '../../common/types.js'; import { createAndOpenPullRequestSession } from '../../browser/pullRequestSessionCreation.js'; @@ -86,6 +86,22 @@ suite('Create Session from Pull Request', () => { }); }); + test('only makes same-repository pull requests without existing sessions available', () => { + const existingPullRequests = { numbers: new Set([1]), headRefs: new Set(['feature-2']) }; + + assert.deepStrictEqual([ + pullRequest(1), + pullRequest(2), + pullRequest(3, { isCrossRepository: true }), + pullRequest(4), + ].map(item => isPullRequestAvailable(item, existingPullRequests)), [ + false, + false, + false, + true, + ]); + }); + test('merges viewer-group results into the loaded catalog without dropping either set', () => { const merged = mergePullRequestSummaries([ pullRequest(1), @@ -295,6 +311,7 @@ function pullRequest(number: number, overrides: Partial