Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2340,6 +2340,10 @@
"title": "%github.copilot.command.refreshAgentSessions%",
"icon": "$(refresh)"
},
{
"command": "github.copilot.cloud.resetWorkspaceConfirmations",
"title": "%github.copilot.command.resetCloudAgentWorkspaceConfirmations%"
},
{
"command": "github.copilot.cloud.sessions.openInBrowser",
"title": "%github.copilot.command.openCopilotAgentSessionsInBrowser%",
Expand Down Expand Up @@ -5173,4 +5177,4 @@
"string_decoder": "npm:string_decoder@1.2.0",
"node-gyp": "npm:node-gyp@10.3.1"
}
}
}
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"github.copilot.command.explainTerminalLastCommand": "Explain Last Terminal Command",
"github.copilot.command.collectWorkspaceIndexDiagnostics": "Collect Workspace Index Diagnostics",
"github.copilot.command.triggerPermissiveSignIn": "Login to GitHub with Full Permissions",
"github.copilot.command.resetCloudAgentWorkspaceConfirmations": "Reset Cloud Agent Workspace Confirmations",
"github.copilot.git.generateCommitMessage": "Generate Commit Message",
"github.copilot.git.resolveMergeConflicts": "Resolve Conflicts with AI",
"github.copilot.devcontainer.generateDevContainerConfig": "Generate Dev Container Configuration",
Expand Down
5 changes: 5 additions & 0 deletions src/extension/chatSessions/vscode-node/chatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
cloudSessionsProvider.refresh();
})
);
this.copilotCloudRegistrations.add(
vscode.commands.registerCommand('github.copilot.cloud.resetWorkspaceConfirmations', () => {
cloudSessionsProvider.resetWorkspaceContext();
})
);
this.copilotCloudRegistrations.add(
vscode.commands.registerCommand('github.copilot.cloud.sessions.openInBrowser', async (chatSessionItem: vscode.ChatSessionItem) => {
cloudSessionsProvider.openSessionsInBrowser(chatSessionItem);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,19 @@ import { PermissionRequest, requestPermission } from '../../agents/copilotcli/no
import { ChatSummarizerProvider } from '../../prompt/node/summarizer';
import { IToolsService } from '../../tools/common/toolsService';
import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
import { ConfirmationResult, CopilotCloudSessionsProvider, UncommittedChangesStep } from './copilotCloudSessionsProvider';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';

const MODELS_OPTION_ID = 'model';
const ISOLATION_OPTION_ID = 'isolation';

const UncommittedChangesStep = 'uncommitted-changes';
type ConfirmationResult = { step: string; accepted: boolean; metadata?: CLIConfirmationMetadata };
interface CLIConfirmationMetadata {
prompt: string;
references?: readonly vscode.ChatPromptReference[];
chatContext: vscode.ChatContext;
}

// Track model selections per session
// TODO@rebornix: we should have proper storage for the session model preference (revisit with API)
const _sessionModel: Map<string, vscode.ChatSessionProviderOptionItem | undefined> = new Map();
Expand Down Expand Up @@ -479,18 +487,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}

const prompt = request.prompt.substring('/delegate'.length).trim();
if (!await this.cloudSessionProvider.tryHandleUncommittedChanges({
prompt: prompt,
chatContext: context
}, stream, token)) {
const prInfo = await this.cloudSessionProvider.createDelegatedChatSession({
prompt,
chatContext: context
}, stream, token);
if (prInfo) {
await this.recordPushToSession(session, request.prompt, prInfo);
}



const prInfo = await this.cloudSessionProvider.delegate(request, stream, context, token, { prompt, chatContext: context });
if (prInfo) {
await this.recordPushToSession(session, request.prompt, prInfo);
}

}

private getAcceptedRejectedConfirmationData(request: vscode.ChatRequest): ConfirmationResult[] {
Expand All @@ -513,15 +517,15 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
return {};
}

const prInfo = await this.cloudSessionProvider?.createDelegatedChatSession({
prompt: uncommittedChangesData.metadata.prompt,
references: uncommittedChangesData.metadata.references,
autoPushAndCommit: uncommittedChangesData.metadata.autoPushAndCommit,
chatContext: context
}, stream, token);
if (prInfo) {
await this.recordPushToSession(session, prompt, prInfo);
}
// const prInfo = await this.cloudSessionProvider?.createDelegatedChatSession({
// prompt: uncommittedChangesData.metadata.prompt,
// references: uncommittedChangesData.metadata.references,
// // autoPushAndCommit: uncommittedChangesData.metadata.autoPushAndCommit,
// chatContext: context
// }, stream, token);
// if (prInfo) {
// await this.recordPushToSession(session, prompt, prInfo);
// }
return {};
}

Expand Down Expand Up @@ -551,13 +555,13 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
private async recordPushToSession(
session: ICopilotCLISession,
userPrompt: string,
prInfo: { uri: string; title: string; description: string; author: string; linkTag: string }
prInfo: { uri: vscode.Uri; title: string; description: string; author: string; linkTag: string }
): Promise<void> {
// Add user message event
session.addUserMessage(userPrompt);

// Add assistant message event with embedded PR metadata
const assistantMessage = `GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n<pr_metadata uri="${prInfo.uri}" title="${escapeXml(prInfo.title)}" description="${escapeXml(prInfo.description)}" author="${escapeXml(prInfo.author)}" linkTag="${escapeXml(prInfo.linkTag)}"/>`;
const assistantMessage = `GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n<pr_metadata uri="${prInfo.uri.toString()}" title="${escapeXml(prInfo.title)}" description="${escapeXml(prInfo.description)}" author="${escapeXml(prInfo.author)}" linkTag="${escapeXml(prInfo.linkTag)}"/>`;
session.addUserAssistantMessage(assistantMessage);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
import { IGitService } from '../../../platform/git/common/gitService';
import { Repository } from '../../../platform/git/vscode/git';
import { ILogService } from '../../../platform/log/common/logService';
import { getRepoId } from '../vscode/copilotCodingAgentUtils';

export interface GitRepoInfo {
repository: Repository;
Expand All @@ -14,18 +18,101 @@ export interface GitRepoInfo {
}

export class CopilotCloudGitOperationsManager {
constructor(private readonly logService: ILogService) { }
constructor(
private readonly logService: ILogService,
private readonly gitService: IGitService,
private readonly gitExtensionService: IGitExtensionService,
private readonly configurationService: IConfigurationService
) { }

private get autoCommitAndPushEnabled(): boolean {
return this.configurationService.getConfig(ConfigKey.AgentDelegateAutoCommitAndPush);
}

async repoInfo(): Promise<GitRepoInfo> {
// TODO: support selecting remote
// await this.promptAndUpdatePreferredGitHubRemote(true);
const repoId = await getRepoId(this.gitService);
if (!repoId) {
throw new Error(vscode.l10n.t('Repository information is not available. Open a GitHub repository to continue with cloud agent.'));
}
const currentRepository = this.gitService.activeRepository.get();
if (!currentRepository) {
throw new Error(vscode.l10n.t('No active repository found. Open a GitHub repository to continue with cloud agent.'));
}
const git = this.gitExtensionService.getExtensionApi();
const repo = git?.getRepository(currentRepository?.rootUri);
// Checks if user has permission to access the repository
if (!repo) {
throw new Error(
vscode.l10n.t(
'Unable to access {0}. Please check your permissions and try again.',
`\`${repoId.org}/${repoId.repo}\``
)
);
}
return {
repository: repo,
remoteName: repo.state.HEAD?.upstream?.remote ?? currentRepository.upstreamRemote ?? repo.state.remotes?.[0]?.name ?? 'origin',
baseRef: currentRepository.headBranchName ?? 'main'
};
}

async validateRemoteHasBaseRef(stream: vscode.ChatResponseStream): Promise<void> {
const { repository, remoteName, baseRef } = await this.repoInfo();
stream.progress(vscode.l10n.t('Verifying branch \'{0}\' exists on remote \'{1}\'', baseRef, remoteName));
if (repository && remoteName && baseRef) {
try {
const remoteBranches =
(await repository.getBranches({ remote: true }))
.filter(b => b.remote); // Has an associated remote
const expectedRemoteBranch = `${remoteName}/${baseRef}`;
const alternateNames = new Set<string>([
expectedRemoteBranch,
`refs/remotes/${expectedRemoteBranch}`,
baseRef
]);
const hasRemoteBranch = remoteBranches.some(branch => {
if (!branch.name) {
return false;
}
if (branch.remote && branch.remote !== remoteName) {
return false;
}
const candidateName =
(branch.remote && branch.name.startsWith(branch.remote + '/'))
? branch.name
: `${branch.remote}/${branch.name}`;
return alternateNames.has(candidateName);
});

async commitAndPushChanges(repoInfo: GitRepoInfo): Promise<string> {
const { repository, remoteName, baseRef } = repoInfo;
if (!hasRemoteBranch) {
if (this.autoCommitAndPushEnabled) {
this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote. Auto-pushing because autoCommitAndPush is enabled.`);
await repository.push(remoteName, baseRef, true);
} else {
this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote.`);
throw new Error(vscode.l10n.t('The branch \'{0}\' does not exist on remote \'{1}\'. Please push the branch and try again.', baseRef, remoteName));
}
}
} catch (error) {
this.logService.error(`Failed to verify remote branch for cloud agent: ${error instanceof Error ? error.message : String(error)}`);
throw new Error(vscode.l10n.t('Unable to verify that branch \'{0}\' exists on remote \'{1}\'. Please ensure the remote branch is available and try again.', baseRef, remoteName));
}
}
}


async commitAndPushChanges(): Promise<string> {
const { repository, remoteName, baseRef } = await this.repoInfo();
const asyncBranch = await this.generateRandomBranchName(repository, 'copilot');

const commitMessage = vscode.l10n.t('Checkpoint from VS Code for cloud agent session');
try {
await repository.createBranch(asyncBranch, true);
await this.performCommit(asyncBranch, repository, commitMessage);
await repository.push(remoteName, asyncBranch, true);
this.showBranchSwitchNotification(repository, baseRef, asyncBranch);
await this.switchBackToBaseRef(repository, baseRef, asyncBranch);
return asyncBranch;
} catch (error) {
await this.rollbackToOriginalBranch(repository, baseRef);
Expand All @@ -41,6 +128,7 @@ export class CopilotCloudGitOperationsManager {
throw new Error(vscode.l10n.t('Uncommitted changes still detected.'));
}
} catch (error) {
// TODO: stream.progress('waiting for user to manually commit changes');
const commitSuccessful = await this.handleInteractiveCommit(repository);
if (!commitSuccessful) {
throw new Error(vscode.l10n.t('Failed to commit changes. Please commit or stash your changes manually before using the cloud agent.'));
Expand Down Expand Up @@ -115,17 +203,9 @@ export class CopilotCloudGitOperationsManager {
});
}

private showBranchSwitchNotification(repository: Repository, baseRef: string, newRef: string): void {
private async switchBackToBaseRef(repository: Repository, baseRef: string, newRef: string): Promise<void> {
if (repository.state.HEAD?.name !== baseRef) {
const SWAP_BACK_TO_ORIGINAL_BRANCH = vscode.l10n.t('Swap back to \'{0}\'', baseRef);
vscode.window.showInformationMessage(
vscode.l10n.t('Pending changes pushed to remote branch \'{0}\'.', newRef),
SWAP_BACK_TO_ORIGINAL_BRANCH,
).then(async (selection) => {
if (selection === SWAP_BACK_TO_ORIGINAL_BRANCH) {
await repository.checkout(baseRef);
}
});
await repository.checkout(baseRef);
}
}

Expand Down
Loading