Skip to content

Commit 765950a

Browse files
authored
Support opening Issue/PR links within VS Code (#6959)
Fixes #6869
1 parent 8b295cb commit 765950a

11 files changed

Lines changed: 164 additions & 32 deletions

File tree

src/common/timelineEvent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ export interface CrossReferencedEvent {
118118
source: {
119119
number: number;
120120
url: string;
121+
extensionUrl: string;
121122
title: string;
123+
isIssue: boolean;
122124
};
123125
willCloseTarget: boolean;
124126
}

src/common/uri.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import * as pathUtils from 'path';
1010
import fetch from 'cross-fetch';
1111
import * as vscode from 'vscode';
1212
import { Repository } from '../api/api';
13+
import { EXTENSION_ID } from '../constants';
1314
import { IAccount, ITeam, reviewerId } from '../github/interface';
1415
import { PullRequestModel } from '../github/pullRequestModel';
1516
import { GitChangeType } from './file';
1617
import Logger from './logger';
1718
import { TemporaryState } from './temporaryState';
19+
import { compareIgnoreCase } from './utils';
1820

1921
export interface ReviewUriParams {
2022
path: string;
@@ -500,6 +502,64 @@ export function fromRepoUri(uri: vscode.Uri): RepoUriParams | undefined {
500502
} catch (e) { }
501503
}
502504

505+
export enum UriHandlerPaths {
506+
OpenIssueWebview = '/open-issue-webview',
507+
OpenPullRequestWebview = '/open-pull-request-webview',
508+
}
509+
510+
export interface OpenIssueWebviewUriParams {
511+
owner: string;
512+
repo: string;
513+
issueNumber: number;
514+
}
515+
516+
export async function toOpenIssueWebviewUri(params: OpenIssueWebviewUriParams): Promise<vscode.Uri> {
517+
const query = JSON.stringify(params);
518+
return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenIssueWebview, query }));
519+
}
520+
521+
export function fromOpenIssueWebviewUri(uri: vscode.Uri): OpenIssueWebviewUriParams | undefined {
522+
if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) {
523+
return;
524+
}
525+
if (uri.path !== UriHandlerPaths.OpenIssueWebview) {
526+
return;
527+
}
528+
try {
529+
const query = JSON.parse(uri.query.split('&')[0]);
530+
if (!query.owner || !query.repo || !query.issueNumber) {
531+
return;
532+
}
533+
return query;
534+
} catch (e) { }
535+
}
536+
537+
export interface OpenPullRequestWebviewUriParams {
538+
owner: string;
539+
repo: string;
540+
pullRequestNumber: number;
541+
}
542+
543+
export async function toOpenPullRequestWebviewUri(params: OpenPullRequestWebviewUriParams): Promise<vscode.Uri> {
544+
const query = JSON.stringify(params);
545+
return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestWebview, query }));
546+
}
547+
548+
export function fromOpenPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined {
549+
if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) {
550+
return;
551+
}
552+
if (uri.path !== UriHandlerPaths.OpenPullRequestWebview) {
553+
return;
554+
}
555+
try {
556+
const query = JSON.parse(uri.query.split('&')[0]);
557+
if (!query.owner || !query.repo || !query.pullRequestNumber) {
558+
return;
559+
}
560+
return query;
561+
} catch (e) { }
562+
}
503563

504564
export enum Schemes {
505565
File = 'file',
@@ -525,11 +585,3 @@ export function resolvePath(from: vscode.Uri, to: string) {
525585
return pathUtils.posix.resolve(from.path, to);
526586
}
527587
}
528-
529-
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
530-
public handleUri(uri: vscode.Uri) {
531-
this.fire(uri);
532-
}
533-
}
534-
535-
export const handler = new UriEventHandler();

src/extension.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Resource } from './common/resources';
1818
import { BRANCH_PUBLISH, EXPERIMENTAL_CHAT, EXPERIMENTAL_NOTIFICATIONS, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE, SHOW_INLINE_OPEN_FILE_ACTION } from './common/settingKeys';
1919
import { initBasedOnSettingChange } from './common/settingsUtils';
2020
import { TemporaryState } from './common/temporaryState';
21-
import { Schemes, handler as uriHandler } from './common/uri';
21+
import { Schemes } from './common/uri';
2222
import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants';
2323
import { createExperimentationService, ExperimentationTelemetry } from './experimentationService';
2424
import { CredentialStore } from './github/credentials';
@@ -32,6 +32,7 @@ import { ChatParticipant, ChatParticipantState } from './lm/participants';
3232
import { registerTools } from './lm/tools/tools';
3333
import { migrate } from './migrations';
3434
import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar';
35+
import { UriHandler } from './uriHandler';
3536
import { CommentDecorationProvider } from './view/commentDecorationProvider';
3637
import { CompareChanges } from './view/compareChangesTreeDataProvider';
3738
import { CreatePullRequestHelper } from './view/createPullRequestHelper';
@@ -119,8 +120,6 @@ async function init(
119120
}),
120121
);
121122

122-
context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
123-
124123
// Sort the repositories to match folders in a multiroot workspace (if possible).
125124
const workspaceFolders = vscode.workspace.workspaceFolders;
126125
if (workspaceFolders) {
@@ -241,6 +240,7 @@ async function init(
241240
registerPostCommitCommandsProvider(reposManager, git);
242241

243242
initChat(context, credentialStore, reposManager);
243+
context.subscriptions.push(vscode.window.registerUriHandler(new UriHandler(reposManager, telemetry, context)));
244244

245245
// Make sure any compare changes tabs, which come from the create flow, are closed.
246246
CompareChanges.closeTabs();

src/github/folderRepositoryManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,15 +1720,15 @@ export class FolderRepositoryManager extends Disposable {
17201720
input
17211721
}
17221722
})
1723-
.then(result => {
1723+
.then(async (result) => {
17241724
Logger.debug(`Merging PR: ${pullRequest.number}} - done`, this.id);
17251725

17261726
/* __GDPR__
17271727
"pr.merge.success" : {}
17281728
*/
17291729
this.telemetry.sendTelemetryEvent('pr.merge.success');
17301730
this._onDidMergePullRequest.fire();
1731-
return { merged: true, message: '', timeline: parseGraphQLTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], pullRequest.githubRepository) };
1731+
return { merged: true, message: '', timeline: await parseGraphQLTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], pullRequest.githubRepository) };
17321732
})
17331733
.catch(e => {
17341734
/* __GDPR__

src/github/graphql.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,16 @@ export interface CrossReferencedEvent {
4040
actor: Actor;
4141
createdAt: string;
4242
source: {
43+
__typename: string;
4344
number: number;
4445
url: string;
4546
title: string;
47+
repository: {
48+
name: string;
49+
owner: {
50+
login: string;
51+
};
52+
}
4653
};
4754
willCloseTarget: boolean;
4855
}

src/github/issueModel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ export class IssueModel<TItem extends Issue = Issue> {
345345
return [];
346346
}
347347
const ret = data.repository.pullRequest.timelineItems.nodes;
348-
const events = parseGraphQLTimelineEvents(ret, githubRepository);
348+
const events = await parseGraphQLTimelineEvents(ret, githubRepository);
349349

350350
return events;
351351
} catch (e) {

src/github/pullRequestModel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1185,7 +1185,7 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
11851185
}
11861186

11871187
const ret = data.repository?.pullRequest.timelineItems.nodes;
1188-
const events = ret ? parseGraphQLTimelineEvents(ret, this.githubRepository) : [];
1188+
const events = ret ? await parseGraphQLTimelineEvents(ret, this.githubRepository) : [];
11891189

11901190
this.addReviewTimelineEventComments(events, reviewThreads);
11911191
insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head);

src/github/queriesShared.gql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,23 @@ fragment CrossReferencedEvent on CrossReferencedEvent {
152152
number
153153
url
154154
title
155+
repository: baseRepository {
156+
owner {
157+
login
158+
}
159+
name
160+
}
155161
}
156162
... on Issue {
157163
number
158164
url
159165
title
166+
repository {
167+
owner {
168+
login
169+
}
170+
name
171+
}
160172
}
161173
}
162174
willCloseTarget

src/github/utils.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Remote } from '../common/remote';
1818
import { Resource } from '../common/resources';
1919
import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys';
2020
import * as Common from '../common/timelineEvent';
21-
import { DataUri } from '../common/uri';
21+
import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri';
2222
import { gitHubLabelColor, uniqBy } from '../common/utils';
2323
import { OctokitCommon } from './common';
2424
import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager';
@@ -983,7 +983,7 @@ export function parseGraphQLReviewEvent(
983983
};
984984
}
985985

986-
export function parseGraphQLTimelineEvents(
986+
export async function parseGraphQLTimelineEvents(
987987
events: (
988988
| GraphQL.MergedEvent
989989
| GraphQL.Review
@@ -994,9 +994,9 @@ export function parseGraphQLTimelineEvents(
994994
| GraphQL.CrossReferencedEvent
995995
)[],
996996
githubRepository: GitHubRepository,
997-
): Common.TimelineEvent[] {
997+
): Promise<Common.TimelineEvent[]> {
998998
const normalizedEvents: Common.TimelineEvent[] = [];
999-
events.forEach(event => {
999+
for (const event of events) {
10001000
const type = convertGraphQLEventType(event.__typename);
10011001

10021002
switch (type) {
@@ -1014,7 +1014,7 @@ export function parseGraphQLTimelineEvents(
10141014
graphNodeId: commentEvent.id,
10151015
createdAt: commentEvent.createdAt,
10161016
});
1017-
return;
1017+
break;
10181018
case Common.EventType.Reviewed:
10191019
const reviewEvent = event as GraphQL.Review;
10201020
normalizedEvents.push({
@@ -1029,7 +1029,7 @@ export function parseGraphQLTimelineEvents(
10291029
state: reviewEvent.state,
10301030
id: reviewEvent.databaseId,
10311031
});
1032-
return;
1032+
break;
10331033
case Common.EventType.Committed:
10341034
const commitEv = event as GraphQL.Commit;
10351035
normalizedEvents.push({
@@ -1043,7 +1043,7 @@ export function parseGraphQLTimelineEvents(
10431043
message: commitEv.commit.message,
10441044
authoredDate: new Date(commitEv.commit.authoredDate),
10451045
} as Common.CommitEvent); // TODO remove cast
1046-
return;
1046+
break;
10471047
case Common.EventType.Merged:
10481048
const mergeEv = event as GraphQL.MergedEvent;
10491049

@@ -1058,7 +1058,7 @@ export function parseGraphQLTimelineEvents(
10581058
url: mergeEv.url,
10591059
graphNodeId: mergeEv.id,
10601060
});
1061-
return;
1061+
break;
10621062
case Common.EventType.Assigned:
10631063
const assignEv = event as GraphQL.AssignedEvent;
10641064

@@ -1069,7 +1069,7 @@ export function parseGraphQLTimelineEvents(
10691069
actor: parseAccount(assignEv.actor),
10701070
createdAt: assignEv.createdAt,
10711071
});
1072-
return;
1072+
break;
10731073
case Common.EventType.HeadRefDeleted:
10741074
const deletedEv = event as GraphQL.HeadRefDeletedEvent;
10751075

@@ -1080,23 +1080,28 @@ export function parseGraphQLTimelineEvents(
10801080
createdAt: deletedEv.createdAt,
10811081
headRef: deletedEv.headRefName,
10821082
});
1083-
return;
1083+
break;
10841084
case Common.EventType.CrossReferenced:
10851085
const crossRefEv = event as GraphQL.CrossReferencedEvent;
1086-
1086+
const isIssue = crossRefEv.source.__typename === 'Issue';
1087+
const extensionUrl = isIssue
1088+
? await toOpenIssueWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, issueNumber: crossRefEv.source.number })
1089+
: await toOpenPullRequestWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, pullRequestNumber: crossRefEv.source.number });
10871090
normalizedEvents.push({
10881091
id: crossRefEv.id,
10891092
event: type,
10901093
actor: parseAccount(crossRefEv.actor, githubRepository),
10911094
createdAt: crossRefEv.createdAt,
10921095
source: {
10931096
url: crossRefEv.source.url,
1097+
extensionUrl: extensionUrl.toString(),
10941098
number: crossRefEv.source.number,
1095-
title: crossRefEv.source.title
1099+
title: crossRefEv.source.title,
1100+
isIssue
10961101
},
10971102
willCloseTarget: crossRefEv.willCloseTarget
10981103
});
1099-
return;
1104+
break;
11001105
case Common.EventType.Closed:
11011106
const closedEv = event as GraphQL.ClosedEvent;
11021107

@@ -1106,7 +1111,7 @@ export function parseGraphQLTimelineEvents(
11061111
actor: parseAccount(closedEv.actor, githubRepository),
11071112
createdAt: closedEv.createdAt,
11081113
});
1109-
return;
1114+
break;
11101115
case Common.EventType.Reopened:
11111116
const reopenedEv = event as GraphQL.ReopenedEvent;
11121117

@@ -1116,11 +1121,11 @@ export function parseGraphQLTimelineEvents(
11161121
actor: parseAccount(reopenedEv.actor, githubRepository),
11171122
createdAt: reopenedEv.createdAt,
11181123
});
1119-
return;
1124+
break;
11201125
default:
11211126
break;
11221127
}
1123-
});
1128+
}
11241129

11251130
return normalizedEvents;
11261131
}

src/uriHandler.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 { ITelemetry } from './common/telemetry';
8+
import { fromOpenIssueWebviewUri, fromOpenPullRequestWebviewUri, UriHandlerPaths } from './common/uri';
9+
import { IssueOverviewPanel } from './github/issueOverview';
10+
import { PullRequestOverviewPanel } from './github/pullRequestOverview';
11+
import { RepositoriesManager } from './github/repositoriesManager';
12+
13+
export class UriHandler implements vscode.UriHandler {
14+
constructor(private readonly _reposManagers: RepositoriesManager,
15+
private readonly _telemetry: ITelemetry,
16+
private readonly _context: vscode.ExtensionContext
17+
) { }
18+
19+
async handleUri(uri: vscode.Uri): Promise<void> {
20+
switch (uri.path) {
21+
case UriHandlerPaths.OpenIssueWebview:
22+
return this._openIssueWebview(uri);
23+
case UriHandlerPaths.OpenPullRequestWebview:
24+
return this._openPullRequestWebview(uri);
25+
}
26+
}
27+
28+
private async _openIssueWebview(uri: vscode.Uri): Promise<void> {
29+
const params = fromOpenIssueWebviewUri(uri);
30+
if (!params) {
31+
return;
32+
}
33+
const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0];
34+
const issue = await folderManager.resolveIssue(params.owner, params.repo, params.issueNumber, true);
35+
if (!issue) {
36+
return;
37+
}
38+
return IssueOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, issue);
39+
}
40+
41+
private async _openPullRequestWebview(uri: vscode.Uri): Promise<void> {
42+
const params = fromOpenPullRequestWebviewUri(uri);
43+
if (!params) {
44+
return;
45+
}
46+
const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0];
47+
const pullRequest = await folderManager.resolvePullRequest(params.owner, params.repo, params.pullRequestNumber);
48+
if (!pullRequest) {
49+
return;
50+
}
51+
return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, pullRequest);
52+
}
53+
54+
}

0 commit comments

Comments
 (0)