diff --git a/src/commands.ts b/src/commands.ts index 0c16fcc689..a247d7e051 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -79,11 +79,16 @@ export async function openDescription( if (revealNode) { descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); } + const identity = { + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + number: issue.number + }; // Create and show a new webview if (issue instanceof PullRequestModel) { - await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, issue, undefined, preserveFocus); + await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue, undefined, preserveFocus); } else { - await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, issue); + await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue); /* __GDPR__ "issue.openDescription" : {} */ @@ -1016,8 +1021,13 @@ export function registerCommands( const pr = descriptionNode.pullRequestModel; const pullRequest = ensurePR(folderManager, pr); descriptionNode.reveal(descriptionNode, { select: true, focus: true }); + const identity = { + owner: pullRequest.remote.owner, + repo: pullRequest.remote.repositoryName, + number: pullRequest.number + }; // Create and show a new webview - PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, pullRequest, true); + PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, identity, pullRequest, true); /* __GDPR__ "pr.openDescriptionToTheSide" : {} diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 743ef6ae3f..a734d9c3e4 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -12,7 +12,7 @@ import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, Repo import { IssueModel } from './issueModel'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; import { isInCodespaces, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply } from './views'; +import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity } from './views'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; @@ -34,6 +34,7 @@ export class IssueOverviewPanel extends W protected readonly _panel: vscode.WebviewPanel; protected _item: TItem; + protected _identity: UnresolvedIdentity; protected _folderRepositoryManager: FolderRepositoryManager; protected _scrollPosition = { x: 0, y: 0 }; @@ -41,7 +42,8 @@ export class IssueOverviewPanel extends W telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepositoryManager: FolderRepositoryManager, - issue: IssueModel, + identity: UnresolvedIdentity, + issue?: IssueModel, toTheSide: Boolean = false, _preserveFocus: boolean = true, existingPanel?: vscode.WebviewPanel @@ -58,7 +60,7 @@ export class IssueOverviewPanel extends W if (IssueOverviewPanel.currentPanel) { IssueOverviewPanel.currentPanel._panel.reveal(activeColumn, true); } else { - const title = `Issue #${issue.number.toString()}`; + const title = `Issue #${identity.number.toString()}`; IssueOverviewPanel.currentPanel = new IssueOverviewPanel( telemetry, extensionUri, @@ -71,7 +73,7 @@ export class IssueOverviewPanel extends W ); } - await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); + await IssueOverviewPanel.currentPanel!.updateWithIdentity(folderRepositoryManager, identity, issue); } public static refresh(): void { @@ -287,7 +289,32 @@ export class IssueOverviewPanel extends W // none for issues } - public async update(foldersManager: FolderRepositoryManager, issueModel: TItem, progressLocation?: string): Promise { + /** + * Resolve a model from an unresolved identity. + * Subclasses can override to resolve different types (e.g., pull requests vs issues). + */ + protected async resolveModel(identity: UnresolvedIdentity): Promise { + return this._folderRepositoryManager.resolveIssue( + identity.owner, + identity.repo, + identity.number + ) as Promise; + } + + /** + * Get the display name for the item type (for error messages). + */ + protected getItemTypeName(): string { + return 'issue'; + } + + /** + * Update the panel with an unresolved identity and optional model. + * If no model is provided, it will be resolved from the identity. + */ + public async updateWithIdentity(foldersManager: FolderRepositoryManager, identity: UnresolvedIdentity, issueModel?: TItem, progressLocation?: string): Promise { + this._identity = identity; + if (this._folderRepositoryManager !== foldersManager) { this._folderRepositoryManager = foldersManager; this.registerPrListeners(); @@ -298,19 +325,39 @@ export class IssueOverviewPanel extends W scrollPosition: this._scrollPosition, }); - if (!this._item || (this._item.number !== issueModel.number) || !this._panel.webview.html) { + const isNewItem = !this._item || (this._item.number !== identity.number); + if (isNewItem || !this._panel.webview.html) { this._panel.webview.html = this.getHtmlForWebview(); this._postMessage({ command: 'pr.clear' }); + } + // If no model provided, resolve it from the identity + if (!issueModel) { + const resolvedModel = await this.resolveModel(identity); + if (!resolvedModel) { + throw new Error( + `Failed to resolve ${this.getItemTypeName()} #${identity.number} in ${identity.owner}/${identity.repo}`, + ); + } + issueModel = resolvedModel; } if (progressLocation) { - return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel)); + return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel!)); } else { return this.updateItem(issueModel); } } + public async update(foldersManager: FolderRepositoryManager, issueModel: TItem, progressLocation?: string): Promise { + const identity: UnresolvedIdentity = { + owner: issueModel.remote.owner, + repo: issueModel.remote.repositoryName, + number: issueModel.number + }; + return this.updateWithIdentity(foldersManager, identity, issueModel, progressLocation); + } + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts index 5092d0063f..fdb59e64b7 100644 --- a/src/github/overviewRestorer.ts +++ b/src/github/overviewRestorer.ts @@ -53,20 +53,21 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS repo = await folderManager.createGitHubRepositoryFromOwnerName(state.owner, state.repo); } + const identity = { owner: state.owner, repo: state.repo, number: state.number }; if (state.isIssue) { const issueModel = await repo.getIssue(state.number, true); if (!issueModel) { webviewPanel.dispose(); return; } - return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, issueModel, undefined, true, webviewPanel); + return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, issueModel, undefined, true, webviewPanel); } else { const pullRequestModel = await repo.getPullRequest(state.number, true); if (!pullRequestModel) { webviewPanel.dispose(); return; } - return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, pullRequestModel, undefined, true, webviewPanel); + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, pullRequestModel, undefined, true, webviewPanel); } } diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index f28d93e289..6e56286286 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -25,7 +25,7 @@ import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; import { parseReviewers } from './utils'; -import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType } from './views'; +import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType, UnresolvedIdentity } from './views'; 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'; @@ -64,7 +64,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + return this._folderRepositoryManager.resolvePullRequest( + identity.owner, + identity.repo, + identity.number + ); + } + + protected override getItemTypeName(): string { + return 'Pull Request'; + } + + public override async updateWithIdentity( folderRepositoryManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, + identity: UnresolvedIdentity, + pullRequestModel?: PullRequestModel, + progressLocation?: string ): Promise { - const result = super.update(folderRepositoryManager, pullRequestModel, 'pr:github'); - if (this._folderRepositoryManager !== folderRepositoryManager) { - this.registerPrListeners(); - } + await super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); - await result; // Notify that this PR overview is now active - PullRequestOverviewPanel._onVisible.fire(pullRequestModel); + PullRequestOverviewPanel._onVisible.fire(this._item); + } - return result; + public override async update( + folderRepositoryManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ): Promise { + const identity: UnresolvedIdentity = { + owner: pullRequestModel.remote.owner, + repo: pullRequestModel.remote.repositoryName, + number: pullRequestModel.number + }; + return this.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, 'pr:github'); } protected override async _onDidReceiveMessage(message: IRequestMessage) { diff --git a/src/github/views.ts b/src/github/views.ts index 0433f5ea24..beae02bebf 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -193,4 +193,14 @@ export interface CodingAgentContext extends SessionLinkInfo { export interface ChangeBaseReply { base: string; events: TimelineEvent[]; +} + +/** + * Represents an unresolved PR or issue identity - just enough info to show the overview + * panel before the full model is loaded. + */ +export interface UnresolvedIdentity { + owner: string; + repo: string; + number: number; } \ No newline at end of file diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 7c09783e8f..da876ae8ae 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -1528,7 +1528,12 @@ ${options?.body ?? ''}\n await vscode.env.clipboard.writeText(issue.html_url); break; case openIssue: - await IssueOverviewPanel.createOrShow(this.telemetry, this.context.extensionUri, constFolderManager, issue); + const identity = { + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + number: issue.number + }; + await IssueOverviewPanel.createOrShow(this.telemetry, this.context.extensionUri, constFolderManager, identity, issue); break; } }); diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index 9498318b4c..0af58e3254 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -82,8 +82,9 @@ describe('PullRequestOverview', function () { const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); + const identity = { owner: prModel.remote.owner, repo: prModel.remote.repositoryName, number: prModel.number }; - await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity, prModel); assert( createWebviewPanel.calledWith(sinonMatch.string, 'Pull Request #1000', vscode.ViewColumn.One, { @@ -116,12 +117,13 @@ describe('PullRequestOverview', function () { const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); const prModel0 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem0); + const identity0 = { owner: prModel0.remote.owner, repo: prModel0.remote.repositoryName, number: prModel0.number }; const resolveStub = sinon.stub(pullRequestManager, 'resolvePullRequest').resolves(prModel0); sinon.stub(prModel0, 'getReviewRequests').resolves([]); sinon.stub(prModel0, 'getTimelineEvents').resolves([]); sinon.stub(prModel0, 'validateDraftMode').resolves(true); sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel0); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity0, prModel0); const panel0 = PullRequestOverviewPanel.currentPanel; assert.notStrictEqual(panel0, undefined); @@ -130,12 +132,13 @@ describe('PullRequestOverview', function () { const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); const prModel1 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem1); + const identity1 = { owner: prModel1.remote.owner, repo: prModel1.remote.repositoryName, number: prModel1.number }; resolveStub.resolves(prModel1); sinon.stub(prModel1, 'getReviewRequests').resolves([]); sinon.stub(prModel1, 'getTimelineEvents').resolves([]); sinon.stub(prModel1, 'validateDraftMode').resolves(true); sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel1); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity1, prModel1); assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); assert.strictEqual(createWebviewPanel.callCount, 1); diff --git a/src/uriHandler.ts b/src/uriHandler.ts index ecd28798bf..429fa1e7c5 100644 --- a/src/uriHandler.ts +++ b/src/uriHandler.ts @@ -14,6 +14,7 @@ import { IssueOverviewPanel } from './github/issueOverview'; import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { RepositoriesManager } from './github/repositoriesManager'; +import { UnresolvedIdentity } from './github/views'; import { ReviewsManager } from './view/reviewsManager'; export const PENDING_CHECKOUT_PULL_REQUEST_KEY = 'pendingCheckoutPullRequest'; @@ -129,14 +130,11 @@ export class UriHandler implements vscode.UriHandler { 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); + const identity = { owner: params.owner, repo: params.repo, number: params.issueNumber }; + return IssueOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, identity); } - private async _resolvePullRequestFromUri(uri: vscode.Uri): Promise<{ folderManager: FolderRepositoryManager; pullRequest: PullRequestModel } | undefined> { + private async _resolveIdentityFromUri(uri: vscode.Uri): Promise<{ folderManager: FolderRepositoryManager, identity: UnresolvedIdentity } | undefined> { const params = fromOpenOrCheckoutPullRequestWebviewUri(uri); if (!params) { vscode.window.showErrorMessage(vscode.l10n.t('Invalid pull request URI.')); @@ -144,29 +142,41 @@ export class UriHandler implements vscode.UriHandler { 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); + return { folderManager, identity: { owner: params.owner, repo: params.repo, number: params.pullRequestNumber } }; + } + + private async _resolvePullRequestFromIdentity(identity: UnresolvedIdentity, folderManager: FolderRepositoryManager): Promise { + const pullRequest = await folderManager.resolvePullRequest(identity.owner, identity.repo, identity.number); if (!pullRequest) { - vscode.window.showErrorMessage(vscode.l10n.t('Pull request {0}/{1}#{2} not found.', params.owner, params.repo, params.pullRequestNumber)); - Logger.error(`Pull request not found: ${params.owner}/${params.repo}#${params.pullRequestNumber}`, UriHandler.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Pull request {0}/{1}#{2} not found.', identity.owner, identity.repo, identity.number)); + Logger.error(`Pull request not found: ${identity.owner}/${identity.repo}#${identity.number}`, UriHandler.ID); return; } - return { folderManager, pullRequest }; + return pullRequest; } private async _openPullRequestWebview(uri: vscode.Uri): Promise { - const resolved = await this._resolvePullRequestFromUri(uri); + const resolved = await this._resolveIdentityFromUri(uri); if (!resolved) { return; } - return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, resolved.folderManager, resolved.pullRequest); + const pullRequest = await this._resolvePullRequestFromIdentity(resolved.identity, resolved.folderManager); + if (!pullRequest) { + return; + } + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, resolved.folderManager, resolved.identity, pullRequest); } private async _openPullRequestChanges(uri: vscode.Uri): Promise { - const resolved = await this._resolvePullRequestFromUri(uri); + const resolved = await this._resolveIdentityFromUri(uri); if (!resolved) { return; } - return PullRequestModel.openChanges(resolved.folderManager, resolved.pullRequest); + const pullRequest = await this._resolvePullRequestFromIdentity(resolved.identity, resolved.folderManager); + if (!pullRequest) { + return; + } + return PullRequestModel.openChanges(resolved.folderManager, pullRequest); } private async _savePendingCheckoutAndOpenFolder(params: { owner: string; repo: string; pullRequestNumber: number }, folderUri: vscode.Uri): Promise { diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index 0d1e718313..ee06622277 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -491,7 +491,12 @@ export class PullRequestCommentController extends CommentControllerBase implemen public async openReview(): Promise { - await PullRequestOverviewPanel.createOrShow(this._telemetry, this._folderRepoManager.context.extensionUri, this._folderRepoManager, this.pullRequestModel); + const identity = { + owner: this.pullRequestModel.remote.owner, + repo: this.pullRequestModel.remote.repositoryName, + number: this.pullRequestModel.number + }; + await PullRequestOverviewPanel.createOrShow(this._telemetry, this._folderRepoManager.context.extensionUri, this._folderRepoManager, identity, this.pullRequestModel); PullRequestOverviewPanel.scrollToReview(); /* __GDPR__