Skip to content

Commit aeca10e

Browse files
committed
Add external URI opener for PRs and issues
1 parent f2e3f23 commit aeca10e

9 files changed

Lines changed: 210 additions & 6 deletions

File tree

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
],
6161
"activationEvents": [
6262
"onStartupFinished",
63+
"onOpenExternalUri:http",
64+
"onOpenExternalUri:https",
6365
"onFileSystem:newIssue",
6466
"onFileSystem:pr",
6567
"onFileSystem:githubpr",

‎src/commands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Repository } from './api/api';
1010
import { GitErrorCodes } from './api/api1';
1111
import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver';
1212
import { commands } from './common/executeCommands';
13+
import { openWithDefaultExternalOpener } from './common/externalUri';
1314
import Logger from './common/logger';
1415
import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys';
1516
import { editQuery } from './common/settingsUtils';
@@ -116,7 +117,7 @@ export async function openPullRequestOnGitHub(e: PRNode | RepositoryChangesNode
116117
}
117118

118119
function openPullRequestUrlOnGitHub(url: vscode.Uri, telemetry: ITelemetry): void {
119-
vscode.commands.executeCommand('vscode.open', url);
120+
openWithDefaultExternalOpener(url);
120121

121122
/** __GDPR__
122123
"pr.openInGitHub" : {}

‎src/common/externalUri.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
8+
export interface GitHubIssueOrPullRequestUri {
9+
kind: 'issue' | 'pullRequest';
10+
owner: string;
11+
repo: string;
12+
number: number;
13+
}
14+
15+
const GITHUB_PATH_PART = /^[A-Za-z0-9_.-]+$/;
16+
17+
export function parseGitHubIssueOrPullRequestUri(uri: vscode.Uri): GitHubIssueOrPullRequestUri | undefined {
18+
if ((uri.scheme !== 'http' && uri.scheme !== 'https') || uri.authority.toLowerCase() !== 'github.com') {
19+
return undefined;
20+
}
21+
22+
const pathParts = uri.path.split('/').filter(Boolean);
23+
const kind = pathParts[2] === 'pull'
24+
? 'pullRequest'
25+
: pathParts[2] === 'issues'
26+
? 'issue'
27+
: undefined;
28+
if (pathParts.length < 4 || !kind || !GITHUB_PATH_PART.test(pathParts[0]) || !GITHUB_PATH_PART.test(pathParts[1])) {
29+
return undefined;
30+
}
31+
32+
if (!/^[1-9][0-9]*$/.test(pathParts[3])) {
33+
return undefined;
34+
}
35+
36+
const number = Number(pathParts[3]);
37+
if (!Number.isSafeInteger(number)) {
38+
return undefined;
39+
}
40+
41+
return {
42+
kind,
43+
owner: pathParts[0],
44+
repo: pathParts[1],
45+
number,
46+
};
47+
}
48+
49+
export function openWithDefaultExternalOpener(uri: vscode.Uri): Thenable<boolean> {
50+
return vscode.env.openExternal(uri, { allowContributedOpeners: 'default' });
51+
}

‎src/github/externalUriOpener.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { IssueOverviewPanel } from './issueOverview';
8+
import { PullRequestOverviewPanel } from './pullRequestOverview';
9+
import { RepositoriesManager } from './repositoriesManager';
10+
import { parseGitHubIssueOrPullRequestUri } from '../common/externalUri';
11+
import { ITelemetry } from '../common/telemetry';
12+
import { EXTENSION_ID } from '../constants';
13+
14+
export function registerGitHubIssueOrPullRequestExternalUriOpener(
15+
extensionUri: vscode.Uri,
16+
repositoriesManager: RepositoriesManager,
17+
telemetry: ITelemetry,
18+
): vscode.Disposable {
19+
return vscode.window.registerExternalUriOpener(`${EXTENSION_ID}.issueOrPullRequest`, {
20+
canOpenExternalUri(uri) {
21+
if (!parseGitHubIssueOrPullRequestUri(uri)) {
22+
return vscode.ExternalUriOpenerPriority.None;
23+
}
24+
return vscode.ExternalUriOpenerPriority.Preferred;
25+
},
26+
async openExternalUri(_resolvedUri, openContext, token) {
27+
const identity = parseGitHubIssueOrPullRequestUri(openContext.sourceUri);
28+
if (!identity || token.isCancellationRequested) {
29+
return;
30+
}
31+
32+
const folderRepositoryManager = repositoriesManager.getManagerForRepository(identity.owner, identity.repo)
33+
?? repositoriesManager.folderManagers[0];
34+
if (!folderRepositoryManager) {
35+
await vscode.window.showErrorMessage(vscode.l10n.t('Unable to open issue or pull request #{0}: no GitHub repository is available.', identity.number));
36+
return;
37+
}
38+
39+
if (identity.kind === 'pullRequest') {
40+
const pullRequest = await folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true);
41+
if (token.isCancellationRequested) {
42+
return;
43+
}
44+
if (!pullRequest) {
45+
await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo));
46+
return;
47+
}
48+
await PullRequestOverviewPanel.createOrShow(
49+
telemetry,
50+
extensionUri,
51+
folderRepositoryManager,
52+
identity,
53+
pullRequest,
54+
);
55+
} else {
56+
const issue = await folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true);
57+
if (token.isCancellationRequested) {
58+
return;
59+
}
60+
if (!issue) {
61+
await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find issue #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo));
62+
return;
63+
}
64+
await IssueOverviewPanel.createOrShow(
65+
telemetry,
66+
extensionUri,
67+
folderRepositoryManager,
68+
identity,
69+
issue,
70+
);
71+
}
72+
},
73+
}, {
74+
schemes: ['http', 'https'],
75+
label: vscode.l10n.t('Open GitHub Issue or Pull Request'),
76+
});
77+
}

‎src/github/overviewRestorer.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import * as vscode from 'vscode';
77
import { CredentialStore } from './credentials';
8+
import { registerGitHubIssueOrPullRequestExternalUriOpener } from './externalUriOpener';
89
import { FolderRepositoryManager } from './folderRepositoryManager';
910
import { GitHubRepository } from './githubRepository';
1011
import { IssueOverviewPanel } from './issueOverview';
@@ -26,6 +27,7 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS
2627
super();
2728
this._register(vscode.window.registerWebviewPanelSerializer(IssueOverviewPanel.viewType, this));
2829
this._register(vscode.window.registerWebviewPanelSerializer(PullRequestOverviewPanel.viewType, this));
30+
this._register(registerGitHubIssueOrPullRequestExternalUriOpener(_extensionUri, _repositoriesManager, _telemetry));
2931
}
3032

3133
async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: PullRequest): Promise<void> {

‎src/github/pullRequestModel.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import { Repository } from '../api/api';
8989
import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment';
9090
import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk';
9191
import { commands } from '../common/executeCommands';
92+
import { openWithDefaultExternalOpener } from '../common/externalUri';
9293
import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file';
9394
import { GitHubRef } from '../common/githubRef';
9495
import Logger from '../common/logger';
@@ -318,7 +319,7 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
318319
const openString = vscode.l10n.t('Open on GitHub');
319320
vscode.window.showWarningMessage(message, openString).then(action => {
320321
if (action && action === openString) {
321-
vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url));
322+
openWithDefaultExternalOpener(vscode.Uri.parse(this.html_url));
322323
}
323324
});
324325

‎src/github/pullRequestOverview.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { debounce } from '../common/async';
3434
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
3535
import { COPILOT_REVIEWER, COPILOT_REVIEWER_ACCOUNT, COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot';
3636
import { commands, contexts } from '../common/executeCommands';
37+
import { openWithDefaultExternalOpener } from '../common/externalUri';
3738
import { disposeAll } from '../common/lifecycle';
3839
import Logger from '../common/logger';
3940
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<PullRequestMode
748749
}
749750

750751
// Fallback to opening external URL
751-
await vscode.env.openExternal(vscode.Uri.parse(message.args.href));
752+
await openWithDefaultExternalOpener(vscode.Uri.parse(message.args.href));
752753
}
753754

754755
private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise<void> {

‎src/lm/skills/summarize-github-issue-pr-notification/SKILL.md‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ To use this skill, provide a JSON representation of a GitHub issue. The skill wi
1717

1818
- Do not output code. When you try to summarize PR changes, summarize in a textual format.
1919
- Output references to other issues and PRs as Markdown links.
20-
- 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:
21-
- [#123](https://github.com/${owner}/${repo}/issues/123)
22-
- [#123](https://github.com/${owner}/${repo}/pull/123)
2320
- Comments should be summarized with the author first. Ex:
2421
- @username: This is a comment that summarizes the main point of the comment.
2522
- 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.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { default as assert } from 'assert';
7+
import * as vscode from 'vscode';
8+
import { parseGitHubIssueOrPullRequestUri } from '../../common/externalUri';
9+
10+
describe('externalUri', () => {
11+
describe('parseGitHubIssueOrPullRequestUri', () => {
12+
it('parses a GitHub pull request URL', () => {
13+
const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/pull/123'));
14+
15+
assert.deepStrictEqual(result, {
16+
kind: 'pullRequest',
17+
owner: 'microsoft',
18+
repo: 'vscode',
19+
number: 123,
20+
});
21+
});
22+
23+
it('parses pull request subpages, queries, and fragments', () => {
24+
const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/pull/123/files?diff=split#discussion_r1'));
25+
26+
assert.deepStrictEqual(result, {
27+
kind: 'pullRequest',
28+
owner: 'microsoft',
29+
repo: 'vscode',
30+
number: 123,
31+
});
32+
});
33+
34+
it('parses a GitHub issue URL', () => {
35+
const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('https://github.com/microsoft/vscode/issues/456#issuecomment-1'));
36+
37+
assert.deepStrictEqual(result, {
38+
kind: 'issue',
39+
owner: 'microsoft',
40+
repo: 'vscode',
41+
number: 456,
42+
});
43+
});
44+
45+
it('supports HTTP and case-insensitive GitHub hosts', () => {
46+
const result = parseGitHubIssueOrPullRequestUri(vscode.Uri.parse('http://GitHub.com/owner/repo_name/pull/1'));
47+
48+
assert.deepStrictEqual(result, {
49+
kind: 'pullRequest',
50+
owner: 'owner',
51+
repo: 'repo_name',
52+
number: 1,
53+
});
54+
});
55+
56+
for (const url of [
57+
'https://example.com/microsoft/vscode/pull/123',
58+
'https://github.com:443/microsoft/vscode/pull/123',
59+
'https://github.com/microsoft/vscode/issue/123',
60+
'https://github.com/microsoft/vscode/pulls/123',
61+
'https://github.com/microsoft/vscode/pull/0',
62+
'https://github.com/microsoft/vscode/pull/not-a-number',
63+
'https://github.com/microsoft/vscode/pull/999999999999999999999',
64+
'https://github.com/microsoft/vscode/pull/123.diff',
65+
'https://github.com/microsoft/vscode/pull',
66+
]) {
67+
it(`does not parse ${url}`, () => {
68+
assert.strictEqual(parseGitHubIssueOrPullRequestUri(vscode.Uri.parse(url)), undefined);
69+
});
70+
}
71+
});
72+
});

0 commit comments

Comments
 (0)