From aeca10ebc29084e74358799e556cba787f6d5531 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:16:12 +0200 Subject: [PATCH] Add external URI opener for PRs and issues --- package.json | 2 + src/commands.ts | 3 +- src/common/externalUri.ts | 51 ++++++++++++ src/github/externalUriOpener.ts | 77 +++++++++++++++++++ src/github/overviewRestorer.ts | 2 + src/github/pullRequestModel.ts | 3 +- src/github/pullRequestOverview.ts | 3 +- .../SKILL.md | 3 - src/test/common/externalUri.test.ts | 72 +++++++++++++++++ 9 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 src/common/externalUri.ts create mode 100644 src/github/externalUriOpener.ts create mode 100644 src/test/common/externalUri.test.ts diff --git a/package.json b/package.json index be10efce60..397a280eb5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ ], "activationEvents": [ "onStartupFinished", + "onOpenExternalUri:http", + "onOpenExternalUri:https", "onFileSystem:newIssue", "onFileSystem:pr", "onFileSystem:githubpr", diff --git a/src/commands.ts b/src/commands.ts index 77fe0bdc29..6e00eebd8c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,6 +10,7 @@ import { Repository } from './api/api'; import { GitErrorCodes } from './api/api1'; import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; import { commands } from './common/executeCommands'; +import { openWithDefaultExternalOpener } from './common/externalUri'; import Logger from './common/logger'; import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; import { editQuery } from './common/settingsUtils'; @@ -116,7 +117,7 @@ export async function openPullRequestOnGitHub(e: PRNode | RepositoryChangesNode } function openPullRequestUrlOnGitHub(url: vscode.Uri, telemetry: ITelemetry): void { - vscode.commands.executeCommand('vscode.open', url); + openWithDefaultExternalOpener(url); /** __GDPR__ "pr.openInGitHub" : {} diff --git a/src/common/externalUri.ts b/src/common/externalUri.ts new file mode 100644 index 0000000000..555df4967b --- /dev/null +++ b/src/common/externalUri.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export interface GitHubIssueOrPullRequestUri { + kind: 'issue' | 'pullRequest'; + owner: string; + repo: string; + number: number; +} + +const GITHUB_PATH_PART = /^[A-Za-z0-9_.-]+$/; + +export function parseGitHubIssueOrPullRequestUri(uri: vscode.Uri): GitHubIssueOrPullRequestUri | undefined { + if ((uri.scheme !== 'http' && uri.scheme !== 'https') || uri.authority.toLowerCase() !== 'github.com') { + return undefined; + } + + const pathParts = uri.path.split('/').filter(Boolean); + const kind = pathParts[2] === 'pull' + ? 'pullRequest' + : pathParts[2] === 'issues' + ? 'issue' + : undefined; + if (pathParts.length < 4 || !kind || !GITHUB_PATH_PART.test(pathParts[0]) || !GITHUB_PATH_PART.test(pathParts[1])) { + return undefined; + } + + if (!/^[1-9][0-9]*$/.test(pathParts[3])) { + return undefined; + } + + const number = Number(pathParts[3]); + if (!Number.isSafeInteger(number)) { + return undefined; + } + + return { + kind, + owner: pathParts[0], + repo: pathParts[1], + number, + }; +} + +export function openWithDefaultExternalOpener(uri: vscode.Uri): Thenable { + return vscode.env.openExternal(uri, { allowContributedOpeners: 'default' }); +} diff --git a/src/github/externalUriOpener.ts b/src/github/externalUriOpener.ts new file mode 100644 index 0000000000..767ee54765 --- /dev/null +++ b/src/github/externalUriOpener.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IssueOverviewPanel } from './issueOverview'; +import { PullRequestOverviewPanel } from './pullRequestOverview'; +import { RepositoriesManager } from './repositoriesManager'; +import { parseGitHubIssueOrPullRequestUri } from '../common/externalUri'; +import { ITelemetry } from '../common/telemetry'; +import { EXTENSION_ID } from '../constants'; + +export function registerGitHubIssueOrPullRequestExternalUriOpener( + extensionUri: vscode.Uri, + repositoriesManager: RepositoriesManager, + telemetry: ITelemetry, +): vscode.Disposable { + return vscode.window.registerExternalUriOpener(`${EXTENSION_ID}.issueOrPullRequest`, { + canOpenExternalUri(uri) { + if (!parseGitHubIssueOrPullRequestUri(uri)) { + return vscode.ExternalUriOpenerPriority.None; + } + return vscode.ExternalUriOpenerPriority.Preferred; + }, + async openExternalUri(_resolvedUri, openContext, token) { + const identity = parseGitHubIssueOrPullRequestUri(openContext.sourceUri); + if (!identity || token.isCancellationRequested) { + return; + } + + const folderRepositoryManager = repositoriesManager.getManagerForRepository(identity.owner, identity.repo) + ?? repositoriesManager.folderManagers[0]; + if (!folderRepositoryManager) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to open issue or pull request #{0}: no GitHub repository is available.', identity.number)); + return; + } + + if (identity.kind === 'pullRequest') { + const pullRequest = await folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true); + if (token.isCancellationRequested) { + return; + } + if (!pullRequest) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); + return; + } + await PullRequestOverviewPanel.createOrShow( + telemetry, + extensionUri, + folderRepositoryManager, + identity, + pullRequest, + ); + } else { + const issue = await folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true); + if (token.isCancellationRequested) { + return; + } + if (!issue) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find issue #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); + return; + } + await IssueOverviewPanel.createOrShow( + telemetry, + extensionUri, + folderRepositoryManager, + identity, + issue, + ); + } + }, + }, { + schemes: ['http', 'https'], + label: vscode.l10n.t('Open GitHub Issue or Pull Request'), + }); +} diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts index 550379fb75..f5f95f6640 100644 --- a/src/github/overviewRestorer.ts +++ b/src/github/overviewRestorer.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { CredentialStore } from './credentials'; +import { registerGitHubIssueOrPullRequestExternalUriOpener } from './externalUriOpener'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; import { IssueOverviewPanel } from './issueOverview'; @@ -26,6 +27,7 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS super(); this._register(vscode.window.registerWebviewPanelSerializer(IssueOverviewPanel.viewType, this)); this._register(vscode.window.registerWebviewPanelSerializer(PullRequestOverviewPanel.viewType, this)); + this._register(registerGitHubIssueOrPullRequestExternalUriOpener(_extensionUri, _repositoriesManager, _telemetry)); } async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: PullRequest): Promise { diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index 1c7ad1eaa8..6bad709ff8 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -89,6 +89,7 @@ import { Repository } from '../api/api'; import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk'; import { commands } from '../common/executeCommands'; +import { openWithDefaultExternalOpener } from '../common/externalUri'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; import { GitHubRef } from '../common/githubRef'; import Logger from '../common/logger'; @@ -318,7 +319,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const openString = vscode.l10n.t('Open on GitHub'); vscode.window.showWarningMessage(message, openString).then(action => { if (action && action === openString) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url)); + openWithDefaultExternalOpener(vscode.Uri.parse(this.html_url)); } }); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 7ac6f0f7fe..6e920cf50a 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -34,6 +34,7 @@ import { debounce } from '../common/async'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { COPILOT_REVIEWER, COPILOT_REVIEWER_ACCOUNT, COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot'; import { commands, contexts } from '../common/executeCommands'; +import { openWithDefaultExternalOpener } from '../common/externalUri'; import { disposeAll } from '../common/lifecycle'; import Logger from '../common/logger'; import { CHECKOUT_DEFAULT_BRANCH, CHECKOUT_PULL_REQUEST_BASE_BRANCH, DEFAULT_MERGE_METHOD, DELETE_BRANCH_AFTER_MERGE, POST_DONE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; @@ -748,7 +749,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { diff --git a/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md b/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md index 30c37baefa..682e73269a 100644 --- a/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md +++ b/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md @@ -17,9 +17,6 @@ To use this skill, provide a JSON representation of a GitHub issue. The skill wi - Do not output code. When you try to summarize PR changes, summarize in a textual format. - Output references to other issues and PRs as Markdown links. -- If a comment references for example issue or PR #123, then output either of the following in the summary depending on if it is an issue or a PR: - - [#123](https://github.com/${owner}/${repo}/issues/123) - - [#123](https://github.com/${owner}/${repo}/pull/123) - Comments should be summarized with the author first. Ex: - @username: This is a comment that summarizes the main point of the comment. - If the content contains images in Markdown format (e.g., ![alt text](image-url)), always preserve them in the output exactly as they appear. Images are important visual content and should not be removed or summarized. diff --git a/src/test/common/externalUri.test.ts b/src/test/common/externalUri.test.ts new file mode 100644 index 0000000000..9e4869fea1 --- /dev/null +++ b/src/test/common/externalUri.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import * as vscode from 'vscode'; +import { parseGitHubIssueOrPullRequestUri } from '../../common/externalUri'; + +describe('externalUri', () => { + describe('parseGitHubIssueOrPullRequestUri', () => { + it('parses a GitHub pull request URL', () => { + const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/pull/123')); + + assert.deepStrictEqual(result, { + kind: 'pullRequest', + owner: 'microsoft', + repo: 'vscode', + number: 123, + }); + }); + + it('parses pull request subpages, queries, and fragments', () => { + const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/pull/123/files?diff=split#discussion_r1')); + + assert.deepStrictEqual(result, { + kind: 'pullRequest', + owner: 'microsoft', + repo: 'vscode', + number: 123, + }); + }); + + it('parses a GitHub issue URL', () => { + const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/issues/456#issuecomment-1')); + + assert.deepStrictEqual(result, { + kind: 'issue', + owner: 'microsoft', + repo: 'vscode', + number: 456, + }); + }); + + it('supports HTTP and case-insensitive GitHub hosts', () => { + const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('http://GitHub.com/owner/repo_name/pull/1')); + + assert.deepStrictEqual(result, { + kind: 'pullRequest', + owner: 'owner', + repo: 'repo_name', + number: 1, + }); + }); + + for (const url of [ + 'https://example.com/microsoft/vscode/pull/123', + 'https://github.com:443/microsoft/vscode/pull/123', + 'https://github.com/microsoft/vscode/issue/123', + 'https://github.com/microsoft/vscode/pulls/123', + 'https://github.com/microsoft/vscode/pull/0', + 'https://github.com/microsoft/vscode/pull/not-a-number', + 'https://github.com/microsoft/vscode/pull/999999999999999999999', + 'https://github.com/microsoft/vscode/pull/123.diff', + 'https://github.com/microsoft/vscode/pull', + ]) { + it(`does not parse ${url}`, () => { + assert.strictEqual(parseGitHubIssueOrPullRequestUri(vscode.Uri.parse(url)), undefined); + }); + } + }); +});