Skip to content

Commit 7d0ecdc

Browse files
authored
Sessions window: create PR slash command (#4307)
* Sessions window: create PR slash command * Review comments
1 parent d227d19 commit 7d0ecdc

2 files changed

Lines changed: 104 additions & 107 deletions

File tree

src/extension/chatSessions/copilotcli/node/copilotcliSession.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export type CopilotCLICommand = 'compact' | 'mcp';
4444
*/
4545
export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'mcp'] as const;
4646

47+
export const builtinSlashSCommands = {
48+
createPr: '/create-pr'
49+
};
50+
4751
/**
4852
* Either a free-form prompt **or** a known command.
4953
*/
@@ -55,6 +59,7 @@ export type CopilotCLISessionInput =
5559
export interface ICopilotCLISession extends IDisposable {
5660
readonly sessionId: string;
5761
readonly title?: string;
62+
readonly createdPullRequestUrl: string | undefined;
5863
readonly onDidChangeTitle: vscode.Event<string>;
5964
readonly status: vscode.ChatSessionStatus | undefined;
6065
readonly onDidChangeStatus: vscode.Event<vscode.ChatSessionStatus | undefined>;
@@ -78,6 +83,10 @@ export interface ICopilotCLISession extends IDisposable {
7883

7984
export class CopilotCLISession extends DisposableStore implements ICopilotCLISession {
8085
public readonly sessionId: string;
86+
private _createdPullRequestUrl: string | undefined;
87+
public get createdPullRequestUrl(): string | undefined {
88+
return this._createdPullRequestUrl;
89+
}
8190
private _status?: vscode.ChatSessionStatus;
8291
public get status(): vscode.ChatSessionStatus | undefined {
8392
return this._status;
@@ -180,6 +189,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
180189
if (this.isDisposed) {
181190
throw new Error('Session disposed');
182191
}
192+
this._createdPullRequestUrl = undefined;
183193
const label = 'prompt' in input ? input.prompt : `/${input.command}`;
184194
const promptLabel = label.length > 50 ? label.substring(0, 47) + '...' : label;
185195
const capturingToken = new CapturingToken(`Background Agent | ${promptLabel}`, 'worktree', false, true);
@@ -226,6 +236,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
226236
token: vscode.CancellationToken,
227237
): Promise<void> {
228238
this.attachments.push(...attachments);
239+
this._createdPullRequestUrl = undefined;
229240
const prompt = 'prompt' in input ? input.prompt : `/${input.command}`;
230241
this._pendingPrompt = prompt;
231242
this.logService.info(`[CopilotCLISession] Steering session ${this.sessionId}`);
@@ -451,6 +462,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
451462
})));
452463
disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => {
453464
const toolName = toolNames.get(event.data.toolCallId) || '<unknown>';
465+
if (toolName.endsWith('create_pull_request') && event.data.success) {
466+
const pullRequestUrl = extractPullRequestUrlFromToolResult(event.data.result);
467+
if (pullRequestUrl) {
468+
this._createdPullRequestUrl = pullRequestUrl;
469+
this.logService.trace(`[CopilotCLISession] Captured pull request URL: ${pullRequestUrl}`);
470+
}
471+
}
454472
// Log tool call to request logger
455473
const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined;
456474
const eventData = { ...event.data, error: eventError };
@@ -981,3 +999,43 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
981999
}
9821000
}
9831001

1002+
function extractPullRequestUrlFromToolResult(result: unknown): string | undefined {
1003+
if (!result || typeof result !== 'object') {
1004+
return undefined;
1005+
}
1006+
1007+
const { content } = result as { content?: unknown };
1008+
const text = typeof content === 'string' ? content : JSON.stringify(content);
1009+
1010+
try {
1011+
const parsed: unknown = JSON.parse(text);
1012+
if (parsed && typeof parsed === 'object' && 'url' in parsed) {
1013+
const url = (parsed as { url: unknown }).url;
1014+
if (typeof url === 'string' && isHttpUrl(url)) {
1015+
return url;
1016+
}
1017+
}
1018+
} catch {
1019+
// not JSON
1020+
}
1021+
1022+
const urlMatch = text.match(/https?:\/\/[^\s"'`,;)\]}>]+/);
1023+
if (urlMatch) {
1024+
const cleaned = urlMatch[0].replace(/[.)\]}>]+$/, '');
1025+
if (isHttpUrl(cleaned)) {
1026+
return cleaned;
1027+
}
1028+
}
1029+
1030+
return undefined;
1031+
}
1032+
1033+
function isHttpUrl(value: string): boolean {
1034+
try {
1035+
const parsed = new URL(value);
1036+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
1037+
} catch {
1038+
return false;
1039+
}
1040+
}
1041+

src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts

Lines changed: 46 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio
1212
import { INativeEnvService } from '../../../platform/env/common/envService';
1313
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
1414
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
15-
import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService';
15+
import { IGitService, RepoContext } from '../../../platform/git/common/gitService';
1616
import { toGitUri } from '../../../platform/git/common/utils';
1717
import { ILogService } from '../../../platform/log/common/logService';
1818
import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
@@ -37,7 +37,7 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace
3737
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
3838
import { ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK } from '../copilotcli/node/copilotCli';
3939
import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver';
40-
import { CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
40+
import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
4141
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
4242
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
4343
import { isCopilotCLIPlanAgent } from './copilotCLIPlanAgentProvider';
@@ -1081,6 +1081,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
10811081
: { prompt: `/${request.command}` };
10821082
await session.object.handleRequest(request, input, [], modelId, authInfo, token);
10831083
await this.commitWorktreeChangesIfNeeded(session.object, token);
1084+
} else if (request.prompt && Object.values(builtinSlashSCommands).includes(request.prompt)) {
1085+
await session.object.handleRequest(request, { prompt: request.prompt }, [], modelId, authInfo, token);
1086+
await this.commitWorktreeChangesIfNeeded(session.object, token);
10841087
} else {
10851088
// Construct the full prompt with references to be sent to CLI.
10861089
const plan = request.modeInstructions2 ? isCopilotCLIPlanAgent(request.modeInstructions2) : false;
@@ -1204,6 +1207,38 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
12041207
await this.workspaceFolderService.handleRequestCompleted(workingDirectory);
12051208
}
12061209
}
1210+
1211+
await this.handlePullRequestCreated(session);
1212+
}
1213+
1214+
private async handlePullRequestCreated(session: ICopilotCLISession): Promise<void> {
1215+
const prUrl = session.createdPullRequestUrl;
1216+
if (!prUrl) {
1217+
return;
1218+
}
1219+
1220+
try {
1221+
const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId);
1222+
if (worktreeProperties && worktreeProperties.version === 2) {
1223+
await this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.sessionId, {
1224+
...worktreeProperties,
1225+
pullRequestUrl: prUrl,
1226+
changes: undefined,
1227+
});
1228+
this.sessionItemProvider.notifySessionsChange();
1229+
}
1230+
} catch (error) {
1231+
this.logService.error(`Failed to persist pull request metadata: ${error instanceof Error ? error.message : String(error)}`);
1232+
}
1233+
1234+
const openAction = l10n.t('Open Pull Request');
1235+
const selection = await vscode.window.showInformationMessage(
1236+
l10n.t('Pull request created successfully.'),
1237+
openAction
1238+
);
1239+
if (selection === openAction) {
1240+
await vscode.env.openExternal(vscode.Uri.parse(prUrl));
1241+
}
12071242
}
12081243

12091244
/**
@@ -1845,9 +1880,6 @@ export function registerCLIChatCommands(
18451880
const resource = sessionItemOrResource instanceof vscode.Uri
18461881
? sessionItemOrResource
18471882
: sessionItemOrResource?.resource;
1848-
const sessionLabel = sessionItemOrResource instanceof vscode.Uri
1849-
? undefined
1850-
: sessionItemOrResource?.label;
18511883

18521884
if (!resource) {
18531885
return;
@@ -1857,111 +1889,18 @@ export function registerCLIChatCommands(
18571889
const sessionId = SessionIdForCLI.parse(resource);
18581890
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
18591891
if (!worktreeProperties || worktreeProperties.version !== 2) {
1860-
throw new Error('Create pull request is only supported for v2 worktree sessions');
1861-
}
1862-
1863-
// Get GitHub repo info from the repository
1864-
const repoContext = await gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);
1865-
if (!repoContext) {
1866-
throw new Error('Unable to find repository');
1867-
}
1868-
const repoInfo = getGitHubRepoInfoFromContext(repoContext);
1869-
if (!repoInfo) {
1870-
throw new Error('Unable to determine GitHub repository owner and name');
1871-
}
1872-
1873-
const title = sessionLabel || `Merging ${worktreeProperties.branchName} to ${worktreeProperties.baseBranchName}`;
1874-
// Push the worktree branch to the remote before creating the PR
1875-
const worktreeUri = vscode.Uri.file(worktreeProperties.worktreePath);
1876-
const gitApi = gitExtensionService.getExtensionApi();
1877-
const worktreeRepo = gitApi?.getRepository(worktreeUri);
1878-
if (!worktreeRepo) {
1879-
throw new Error('Unable to find git repository for worktree');
1880-
}
1881-
1882-
// Determine the remote name from repoContext instead of hard-coding 'origin'
1883-
let remoteName = repoContext.upstreamRemote;
1884-
if (!remoteName && repoInfo.remoteUrl && repoContext.remoteFetchUrls) {
1885-
for (let i = 0; i < repoContext.remotes.length; i++) {
1886-
if (repoContext.remoteFetchUrls[i] === repoInfo.remoteUrl) {
1887-
remoteName = repoContext.remotes[i];
1888-
break;
1889-
}
1890-
}
1891-
}
1892-
if (!remoteName) {
1893-
remoteName = 'origin';
1894-
}
1895-
await worktreeRepo.push(remoteName, worktreeProperties.branchName, true);
1896-
1897-
// Find the MCP tool by matching against registered tool names
1898-
const createPrTool = toolsService.tools.find(t => t.name.endsWith('create_pull_request') && t.name.includes('github'));
1899-
if (!createPrTool) {
1900-
throw new Error('GitHub MCP server create_pull_request tool not found. Please ensure the GitHub MCP server is configured and running.');
1901-
}
1902-
1903-
const result = await toolsService.invokeTool(createPrTool.name, {
1904-
toolInvocationToken: undefined,
1905-
input: {
1906-
owner: repoInfo.id.org,
1907-
repo: repoInfo.id.repo,
1908-
title,
1909-
head: worktreeProperties.branchName,
1910-
base: worktreeProperties.baseBranchName,
1911-
body: '',
1912-
},
1913-
}, CancellationToken.None);
1914-
1915-
// Extract the PR URL from the tool result
1916-
let prUrl: string | undefined;
1917-
for (const part of result.content) {
1918-
if (part instanceof vscode.LanguageModelTextPart) {
1919-
try {
1920-
const parsed = JSON.parse(part.value);
1921-
if (parsed.url) {
1922-
prUrl = parsed.url;
1923-
break;
1924-
}
1925-
} catch {
1926-
// Not JSON, ignore
1927-
}
1928-
} else if (part instanceof vscode.LanguageModelDataPart && part.mimeType === 'application/json') {
1929-
try {
1930-
const decoded = new TextDecoder().decode(part.data);
1931-
const parsed = JSON.parse(decoded);
1932-
if (parsed.url) {
1933-
prUrl = parsed.url;
1934-
break;
1935-
}
1936-
} catch {
1937-
// Not valid JSON data, ignore
1938-
}
1939-
}
1940-
}
1941-
1942-
if (prUrl) {
1943-
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {
1944-
...worktreeProperties,
1945-
pullRequestUrl: prUrl,
1946-
changes: undefined,
1947-
});
1948-
copilotcliSessionItemProvider.notifySessionsChange();
1949-
1950-
const openAction = l10n.t('Open Pull Request');
1951-
const selection = await vscode.window.showInformationMessage(
1952-
l10n.t('Pull request created successfully.'),
1953-
openAction
1954-
);
1955-
if (selection === openAction) {
1956-
await vscode.env.openExternal(vscode.Uri.parse(prUrl));
1957-
}
1958-
} else {
1959-
throw new Error('Unable to extract pull request URL from create_pull_request tool result');
1892+
vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.'));
1893+
return;
19601894
}
19611895
} catch (error) {
1962-
logService.error(`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`);
1963-
vscode.window.showErrorMessage(l10n.t('Failed to create pull request'), { modal: true });
1896+
logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`);
1897+
return;
19641898
}
1899+
1900+
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1901+
resource,
1902+
prompt: builtinSlashSCommands.createPr,
1903+
});
19651904
}));
19661905

19671906
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {

0 commit comments

Comments
 (0)