Skip to content

Support opening Issue/PR links within VS Code #6959

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/common/timelineEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ export interface CrossReferencedEvent {
source: {
number: number;
url: string;
extensionUrl: string;
title: string;
isIssue: boolean;
};
willCloseTarget: boolean;
}
Expand Down
68 changes: 60 additions & 8 deletions src/common/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import * as pathUtils from 'path';
import fetch from 'cross-fetch';
import * as vscode from 'vscode';
import { Repository } from '../api/api';
import { EXTENSION_ID } from '../constants';
import { IAccount, ITeam, reviewerId } from '../github/interface';
import { PullRequestModel } from '../github/pullRequestModel';
import { GitChangeType } from './file';
import Logger from './logger';
import { TemporaryState } from './temporaryState';
import { compareIgnoreCase } from './utils';

export interface ReviewUriParams {
path: string;
Expand Down Expand Up @@ -500,6 +502,64 @@ export function fromRepoUri(uri: vscode.Uri): RepoUriParams | undefined {
} catch (e) { }
}

export enum UriHandlerPaths {
OpenIssueWebview = '/open-issue-webview',
OpenPullRequestWebview = '/open-pull-request-webview',
}

export interface OpenIssueWebviewUriParams {
owner: string;
repo: string;
issueNumber: number;
}

export async function toOpenIssueWebviewUri(params: OpenIssueWebviewUriParams): Promise<vscode.Uri> {
const query = JSON.stringify(params);
return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenIssueWebview, query }));
}

export function fromOpenIssueWebviewUri(uri: vscode.Uri): OpenIssueWebviewUriParams | undefined {
if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) {
return;
}
if (uri.path !== UriHandlerPaths.OpenIssueWebview) {
return;
}
try {
const query = JSON.parse(uri.query.split('&')[0]);
if (!query.owner || !query.repo || !query.issueNumber) {
return;
}
return query;
} catch (e) { }
}

export interface OpenPullRequestWebviewUriParams {
owner: string;
repo: string;
pullRequestNumber: number;
}

export async function toOpenPullRequestWebviewUri(params: OpenPullRequestWebviewUriParams): Promise<vscode.Uri> {
const query = JSON.stringify(params);
return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestWebview, query }));
}

export function fromOpenPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined {
if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) {
return;
}
if (uri.path !== UriHandlerPaths.OpenPullRequestWebview) {
return;
}
try {
const query = JSON.parse(uri.query.split('&')[0]);
if (!query.owner || !query.repo || !query.pullRequestNumber) {
return;
}
return query;
} catch (e) { }
}

export enum Schemes {
File = 'file',
Expand All @@ -525,11 +585,3 @@ export function resolvePath(from: vscode.Uri, to: string) {
return pathUtils.posix.resolve(from.path, to);
}
}

class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
public handleUri(uri: vscode.Uri) {
this.fire(uri);
}
}

export const handler = new UriEventHandler();
6 changes: 3 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Resource } from './common/resources';
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';
import { initBasedOnSettingChange } from './common/settingsUtils';
import { TemporaryState } from './common/temporaryState';
import { Schemes, handler as uriHandler } from './common/uri';
import { Schemes } from './common/uri';
import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants';
import { createExperimentationService, ExperimentationTelemetry } from './experimentationService';
import { CredentialStore } from './github/credentials';
Expand All @@ -32,6 +32,7 @@ import { ChatParticipant, ChatParticipantState } from './lm/participants';
import { registerTools } from './lm/tools/tools';
import { migrate } from './migrations';
import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar';
import { UriHandler } from './uriHandler';
import { CommentDecorationProvider } from './view/commentDecorationProvider';
import { CompareChanges } from './view/compareChangesTreeDataProvider';
import { CreatePullRequestHelper } from './view/createPullRequestHelper';
Expand Down Expand Up @@ -119,8 +120,6 @@ async function init(
}),
);

context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));

// Sort the repositories to match folders in a multiroot workspace (if possible).
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
Expand Down Expand Up @@ -241,6 +240,7 @@ async function init(
registerPostCommitCommandsProvider(reposManager, git);

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

// Make sure any compare changes tabs, which come from the create flow, are closed.
CompareChanges.closeTabs();
Expand Down
4 changes: 2 additions & 2 deletions src/github/folderRepositoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,15 +1720,15 @@ export class FolderRepositoryManager extends Disposable {
input
}
})
.then(result => {
.then(async (result) => {
Logger.debug(`Merging PR: ${pullRequest.number}} - done`, this.id);

/* __GDPR__
"pr.merge.success" : {}
*/
this.telemetry.sendTelemetryEvent('pr.merge.success');
this._onDidMergePullRequest.fire();
return { merged: true, message: '', timeline: parseGraphQLTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], pullRequest.githubRepository) };
return { merged: true, message: '', timeline: await parseGraphQLTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], pullRequest.githubRepository) };
})
.catch(e => {
/* __GDPR__
Expand Down
7 changes: 7 additions & 0 deletions src/github/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,16 @@ export interface CrossReferencedEvent {
actor: Actor;
createdAt: string;
source: {
__typename: string;
number: number;
url: string;
title: string;
repository: {
name: string;
owner: {
login: string;
};
}
};
willCloseTarget: boolean;
}
Expand Down
2 changes: 1 addition & 1 deletion src/github/issueModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export class IssueModel<TItem extends Issue = Issue> {
return [];
}
const ret = data.repository.pullRequest.timelineItems.nodes;
const events = parseGraphQLTimelineEvents(ret, githubRepository);
const events = await parseGraphQLTimelineEvents(ret, githubRepository);

return events;
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion src/github/pullRequestModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,7 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
}

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

this.addReviewTimelineEventComments(events, reviewThreads);
insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head);
Expand Down
12 changes: 12 additions & 0 deletions src/github/queriesShared.gql
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,23 @@ fragment CrossReferencedEvent on CrossReferencedEvent {
number
url
title
repository: baseRepository {
owner {
login
}
name
}
}
... on Issue {
number
url
title
repository {
owner {
login
}
name
}
}
}
willCloseTarget
Expand Down
37 changes: 21 additions & 16 deletions src/github/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Remote } from '../common/remote';
import { Resource } from '../common/resources';
import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys';
import * as Common from '../common/timelineEvent';
import { DataUri } from '../common/uri';
import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri';
import { gitHubLabelColor, uniqBy } from '../common/utils';
import { OctokitCommon } from './common';
import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager';
Expand Down Expand Up @@ -983,7 +983,7 @@ export function parseGraphQLReviewEvent(
};
}

export function parseGraphQLTimelineEvents(
export async function parseGraphQLTimelineEvents(
events: (
| GraphQL.MergedEvent
| GraphQL.Review
Expand All @@ -994,9 +994,9 @@ export function parseGraphQLTimelineEvents(
| GraphQL.CrossReferencedEvent
)[],
githubRepository: GitHubRepository,
): Common.TimelineEvent[] {
): Promise<Common.TimelineEvent[]> {
const normalizedEvents: Common.TimelineEvent[] = [];
events.forEach(event => {
for (const event of events) {
const type = convertGraphQLEventType(event.__typename);

switch (type) {
Expand All @@ -1014,7 +1014,7 @@ export function parseGraphQLTimelineEvents(
graphNodeId: commentEvent.id,
createdAt: commentEvent.createdAt,
});
return;
break;
case Common.EventType.Reviewed:
const reviewEvent = event as GraphQL.Review;
normalizedEvents.push({
Expand All @@ -1029,7 +1029,7 @@ export function parseGraphQLTimelineEvents(
state: reviewEvent.state,
id: reviewEvent.databaseId,
});
return;
break;
case Common.EventType.Committed:
const commitEv = event as GraphQL.Commit;
normalizedEvents.push({
Expand All @@ -1043,7 +1043,7 @@ export function parseGraphQLTimelineEvents(
message: commitEv.commit.message,
authoredDate: new Date(commitEv.commit.authoredDate),
} as Common.CommitEvent); // TODO remove cast
return;
break;
case Common.EventType.Merged:
const mergeEv = event as GraphQL.MergedEvent;

Expand All @@ -1058,7 +1058,7 @@ export function parseGraphQLTimelineEvents(
url: mergeEv.url,
graphNodeId: mergeEv.id,
});
return;
break;
case Common.EventType.Assigned:
const assignEv = event as GraphQL.AssignedEvent;

Expand All @@ -1069,7 +1069,7 @@ export function parseGraphQLTimelineEvents(
actor: parseAccount(assignEv.actor),
createdAt: assignEv.createdAt,
});
return;
break;
case Common.EventType.HeadRefDeleted:
const deletedEv = event as GraphQL.HeadRefDeletedEvent;

Expand All @@ -1080,23 +1080,28 @@ export function parseGraphQLTimelineEvents(
createdAt: deletedEv.createdAt,
headRef: deletedEv.headRefName,
});
return;
break;
case Common.EventType.CrossReferenced:
const crossRefEv = event as GraphQL.CrossReferencedEvent;

const isIssue = crossRefEv.source.__typename === 'Issue';
const extensionUrl = isIssue
? await toOpenIssueWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, issueNumber: crossRefEv.source.number })
: await toOpenPullRequestWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, pullRequestNumber: crossRefEv.source.number });
normalizedEvents.push({
id: crossRefEv.id,
event: type,
actor: parseAccount(crossRefEv.actor, githubRepository),
createdAt: crossRefEv.createdAt,
source: {
url: crossRefEv.source.url,
extensionUrl: extensionUrl.toString(),
number: crossRefEv.source.number,
title: crossRefEv.source.title
title: crossRefEv.source.title,
isIssue
},
willCloseTarget: crossRefEv.willCloseTarget
});
return;
break;
case Common.EventType.Closed:
const closedEv = event as GraphQL.ClosedEvent;

Expand All @@ -1106,7 +1111,7 @@ export function parseGraphQLTimelineEvents(
actor: parseAccount(closedEv.actor, githubRepository),
createdAt: closedEv.createdAt,
});
return;
break;
case Common.EventType.Reopened:
const reopenedEv = event as GraphQL.ReopenedEvent;

Expand All @@ -1116,11 +1121,11 @@ export function parseGraphQLTimelineEvents(
actor: parseAccount(reopenedEv.actor, githubRepository),
createdAt: reopenedEv.createdAt,
});
return;
break;
default:
break;
}
});
}

return normalizedEvents;
}
Expand Down
54 changes: 54 additions & 0 deletions src/uriHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* 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 { ITelemetry } from './common/telemetry';
import { fromOpenIssueWebviewUri, fromOpenPullRequestWebviewUri, UriHandlerPaths } from './common/uri';
import { IssueOverviewPanel } from './github/issueOverview';
import { PullRequestOverviewPanel } from './github/pullRequestOverview';
import { RepositoriesManager } from './github/repositoriesManager';

export class UriHandler implements vscode.UriHandler {
constructor(private readonly _reposManagers: RepositoriesManager,
private readonly _telemetry: ITelemetry,
private readonly _context: vscode.ExtensionContext
) { }

async handleUri(uri: vscode.Uri): Promise<void> {
switch (uri.path) {
case UriHandlerPaths.OpenIssueWebview:
return this._openIssueWebview(uri);
case UriHandlerPaths.OpenPullRequestWebview:
return this._openPullRequestWebview(uri);
}
}

private async _openIssueWebview(uri: vscode.Uri): Promise<void> {
const params = fromOpenIssueWebviewUri(uri);
if (!params) {
return;
}
const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0];
const issue = await folderManager.resolveIssue(params.owner, params.repo, params.issueNumber, true);
if (!issue) {
return;
}
return IssueOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, issue);
}

private async _openPullRequestWebview(uri: vscode.Uri): Promise<void> {
const params = fromOpenPullRequestWebviewUri(uri);
if (!params) {
return;
}
const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0];
const pullRequest = await folderManager.resolvePullRequest(params.owner, params.repo, params.pullRequestNumber);
if (!pullRequest) {
return;
}
return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, pullRequest);
}

}
Loading