diff --git a/extensions/github/package.json b/extensions/github/package.json index a9ba2e87d30..bce90fe1812 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -22,7 +22,7 @@ "main": "./out/extension.js", "type": "module", "capabilities": { - "virtualWorkspaces": false, + "virtualWorkspaces": true, "untrustedWorkspaces": { "supported": true } @@ -74,6 +74,11 @@ "command": "github.createPullRequest", "title": "%command.createPullRequest%", "icon": "$(git-pull-request)" + }, + { + "command": "github.openPullRequest", + "title": "%command.openPullRequest%", + "icon": "$(git-pull-request)" } ], "continueEditSession": [ @@ -95,6 +100,10 @@ "command": "github.createPullRequest", "when": "false" }, + { + "command": "github.openPullRequest", + "when": "false" + }, { "command": "github.graph.openOnGitHub", "when": "false" @@ -179,7 +188,13 @@ "command": "github.createPullRequest", "group": "navigation", "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli" + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && !github.hasOpenPullRequest" + }, + { + "command": "github.openPullRequest", + "group": "navigation", + "order": 1, + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && github.hasOpenPullRequest" } ] }, diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index ced536e4bd7..4acc8acabcb 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -6,6 +6,7 @@ "command.openOnGitHub": "Open on GitHub", "command.openOnVscodeDev": "Open in vscode.dev", "command.createPullRequest": "Create Pull Request", + "command.openPullRequest": "Open Pull Request", "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 496772ededf..33acf5a406b 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -8,6 +8,7 @@ import { API as GitAPI, RefType, Repository } from './typings/git.js'; import { publishRepository } from './publish.js'; import { DisposableStore, getRepositoryFromUrl } from './util.js'; import { LinkContext, getCommitLink, getLink, getVscodeDevHost } from './links.js'; +import { getOctokit } from './auth.js'; async function copyVscodeDevLink(gitAPI: GitAPI, useSelection: boolean, context: LinkContext, includeRange = true) { try { @@ -34,46 +35,95 @@ async function openVscodeDevLink(gitAPI: GitAPI): Promise { - if (!sessionResource || !sessionMetadata?.worktreePath) { - return; +interface ResolvedSessionRepo { + repository: Repository; + remoteInfo: { owner: string; repo: string }; + gitRemote: { name: string; fetchUrl: string }; + head: { name: string; upstream?: { name: string; remote: string; commit: string } }; +} + +function resolveSessionRepo(gitAPI: GitAPI, sessionMetadata: { worktreePath?: string } | undefined, showErrors: boolean): ResolvedSessionRepo | undefined { + if (!sessionMetadata?.worktreePath) { + return undefined; } const worktreeUri = vscode.Uri.file(sessionMetadata.worktreePath); const repository = gitAPI.getRepository(worktreeUri); if (!repository) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); - return; + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); + } + return undefined; } - // Find the GitHub remote const remotes = repository.state.remotes .filter(remote => remote.fetchUrl && getRepositoryFromUrl(remote.fetchUrl)); if (remotes.length === 0) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); - return; + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); + } + return undefined; } - // Prefer upstream -> origin -> first const gitRemote = remotes.find(r => r.name === 'upstream') ?? remotes.find(r => r.name === 'origin') ?? remotes[0]; const remoteInfo = getRepositoryFromUrl(gitRemote.fetchUrl!); if (!remoteInfo) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); + } + return undefined; + } + + const head = repository.state.HEAD; + if (!head?.name) { + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + } + return undefined; + } + + return { repository, remoteInfo, gitRemote: { name: gitRemote.name, fetchUrl: gitRemote.fetchUrl! }, head: head as ResolvedSessionRepo['head'] }; +} + +async function checkOpenPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, false); + if (!resolved) { + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); return; } - // Get the current branch (the worktree branch) - const head = repository.state.HEAD; - if (!head?.name) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + try { + const octokit = await getOctokit(); + const { data: openPRs } = await octokit.pulls.list({ + owner: resolved.remoteInfo.owner, + repo: resolved.remoteInfo.repo, + head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, + state: 'all', + }); + + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', openPRs.length > 0); + } catch { + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); + } +} + +async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + if (!sessionResource) { return; } + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, true); + if (!resolved) { + return; + } + + const { repository, remoteInfo, gitRemote, head } = resolved; + // Ensure the branch is published to the remote if (!head.upstream) { try { @@ -96,6 +146,34 @@ async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | u vscode.env.openExternal(vscode.Uri.parse(prUrl)); } +async function openPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, true); + if (!resolved) { + return; + } + + try { + const octokit = await getOctokit(); + const { data: pullRequests } = await octokit.pulls.list({ + owner: resolved.remoteInfo.owner, + repo: resolved.remoteInfo.repo, + head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, + state: 'all', + }); + + if (pullRequests.length > 0) { + vscode.env.openExternal(vscode.Uri.parse(pullRequests[0].html_url)); + return; + } + } catch { + // If the API call fails, fall through to open the repo page + } + + // Fallback: open the repository page + const { remoteInfo } = resolved; + vscode.env.openExternal(vscode.Uri.parse(`https://github.com/${remoteInfo.owner}/${remoteInfo.repo}`)); +} + async function openOnGitHub(repository: Repository, commit: string): Promise { // Get the unique remotes that contain the commit const branches = await repository.getBranches({ contains: commit, remote: true }); @@ -181,5 +259,13 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return createPullRequest(gitAPI, sessionResource, sessionMetadata); })); + disposables.add(vscode.commands.registerCommand('github.openPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return openPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + + disposables.add(vscode.commands.registerCommand('github.checkOpenPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return checkOpenPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + return disposables; } diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index 3e41846d313..2d29fbe475e 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -54,6 +54,7 @@ import { createFileIconThemableTreeContainerScope } from '../../../../workbench/ import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; @@ -236,6 +237,7 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -542,13 +544,24 @@ export class ChangesViewPane extends ViewPane { return files > 0; })); + // Check if a PR exists when the active session changes + this.renderDisposables.add(autorun(reader => { + const sessionResource = activeSessionResource.read(reader); + if (sessionResource) { + const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; + this.commandService.executeCommand('github.checkOpenPullRequest', sessionResource, metadata).catch(() => { /* ignore */ }); + } + })); + this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); const sessionResource = activeSessionResource.read(reader); + const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + menuId, { telemetrySource: 'changesView', menuOptions: isSessionMenu && sessionResource @@ -562,7 +575,7 @@ export class ChangesViewPane extends ViewPane { ); return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; } - if (action.id === 'github.createPullRequest') { + if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') { return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; } if (action.id === 'chatEditing.applyToParentRepo') {