From ffe03cb6b17f61b03603b2d4fc49624e04f83a5d Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:53:08 +0200 Subject: [PATCH 1/3] Load issues faster Fixes #8979 --- src/github/externalUriOpener.ts | 62 +- src/github/githubRepository.ts | 25 +- src/github/issueOverview.ts | 117 +- src/github/pullRequestOverview.ts | 37 +- src/github/queriesShared.gql | 2911 ++++++++++--------- src/github/views.ts | 606 ++-- src/test/github/externalUriOpener.test.ts | 210 +- src/test/github/githubRepository.test.ts | 51 + src/test/github/pullRequestOverview.test.ts | 518 ++-- webviews/common/context.tsx | 6 +- webviews/components/comment.tsx | 10 +- webviews/components/header.tsx | 17 +- webviews/components/sidebar.tsx | 7 +- webviews/editorWebview/app.tsx | 4 +- webviews/editorWebview/overview.tsx | 8 +- webviews/editorWebview/test/app.test.tsx | 59 +- 16 files changed, 2534 insertions(+), 2114 deletions(-) diff --git a/src/github/externalUriOpener.ts b/src/github/externalUriOpener.ts index 934bc42090..8fa78bb09c 100644 --- a/src/github/externalUriOpener.ts +++ b/src/github/externalUriOpener.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { FolderRepositoryManagerResolver } from './folderRepositoryManagerResolver'; +import { IssueModel } from './issueModel'; import { IssueOverviewPanel } from './issueOverview'; import { PullRequestOverviewPanel } from './pullRequestOverview'; import { getGitHubIssueOrPullRequestUriOpenerPriority, openWithDefaultExternalOpener, parseGitHubIssueOrPullRequestUri } from '../common/externalUri'; @@ -45,19 +46,20 @@ class GitHubIssueOrPullRequestExternalUriOpener extends Disposable implements vs } const folderRepositoryManager = this._folderRepositoryManagerResolver.getManagerForRepository(identity.owner, identity.repo); - if (identity.kind === 'pullRequest') { - const pullRequest = folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true, 'overview').then(async (pullRequest) => { - if (token.isCancellationRequested) { - throw new vscode.CancellationError(); - } - if (!pullRequest) { - await openWithDefaultExternalOpener(openContext.sourceUri); - throw new vscode.CancellationError(); - } - return pullRequest; - }); - // Start the webview while the first repository and PR requests are in flight. - try { + const requireModel = async (model: T | undefined): Promise => { + if (token.isCancellationRequested) { + throw new vscode.CancellationError(); + } + if (!model) { + await openWithDefaultExternalOpener(openContext.sourceUri); + throw new vscode.CancellationError(); + } + return model; + }; + + try { + if (identity.kind === 'pullRequest') { + const pullRequest = folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true, 'overview').then(requireModel); await PullRequestOverviewPanel.createOrShow( this._telemetry, this._context.extensionUri, @@ -65,28 +67,22 @@ class GitHubIssueOrPullRequestExternalUriOpener extends Disposable implements vs identity, pullRequest, ); - } catch (error) { - if (!(error instanceof vscode.CancellationError)) { - Logger.error(`Failed to open pull request: ${formatError(error)}`, 'GitHubIssueOrPullRequestExternalUriOpener'); - await vscode.window.showErrorMessage(formatError(error)); - } - } - } else { - const issue = await folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true); - if (token.isCancellationRequested) { - return; + } else { + const issue = folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true).then(requireModel); + await IssueOverviewPanel.createOrShow( + this._telemetry, + this._context.extensionUri, + folderRepositoryManager, + identity, + issue, + ); } - if (!issue) { - await openWithDefaultExternalOpener(openContext.sourceUri); - return; + } catch (error) { + if (!(error instanceof vscode.CancellationError)) { + const itemType = identity.kind === 'pullRequest' ? 'pull request' : 'issue'; + Logger.error(`Failed to open ${itemType}: ${formatError(error)}`, 'GitHubIssueOrPullRequestExternalUriOpener'); + await vscode.window.showErrorMessage(formatError(error)); } - await IssueOverviewPanel.createOrShow( - this._telemetry, - this._context.extensionUri, - folderRepositoryManager, - identity, - issue, - ); } } diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index fdf46c270c..30a69da048 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -78,7 +78,7 @@ import { parseMilestone, restPaginate, } from './utils'; -import { PullRequestPreview } from './views'; +import { IssuePreview, PullRequestPreview } from './views'; import { StackCandidate } from '../../common/views'; import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; @@ -1497,6 +1497,29 @@ export class GitHubRepository extends Disposable { }; } + async getIssuePreview(number: number): Promise { + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error(`Invalid issue number: ${number}`); + } + const { query, remote, schema } = await this.ensure(); + type PreviewData = Omit & { + author: GraphQLAccount | null; + }; + const { data } = await query<{ repository: { issue: PreviewData | null } | null }>({ + query: schema.IssuePreview, + variables: { owner: remote.owner, name: remote.repositoryName, number }, + }); + if (!data.repository?.issue) { + throw new Error(`Unable to load issue preview for ${remote.owner}/${remote.repositoryName}#${number}`); + } + const { author, ...preview } = data.repository.issue; + return { + ...preview, + author: parseAccount(author, this), + isIssue: true, + }; + } + async getPullRequest(id: number, callerName: string, useCache: boolean = false, silent: boolean = false, loadMode: 'default' | 'overview' = 'default'): Promise { if (useCache && this._pullRequestModelsByNumber.has(id)) { Logger.debug(`Using cached pull request model for ${id}`, this.id); diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index aa3beb0a71..cb8c25f209 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -9,12 +9,13 @@ import { CloseResult, OpenLocalFileArgs } from '../../common/views'; import { openItemOnGitHub } from '../commands'; import { decodeBase64, guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './fileUpload'; import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; import { IssueModel } from './issueModel'; import { openIssueOrPullRequestOnGitHub } from './openOnGitHub'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewArgs, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply, UploadPastedFilesArgs } from './views'; +import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, IssuePreview, OverviewItemPreview, ProjectItemsReply, SubmitReviewArgs, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply, UploadPastedFilesArgs } from './views'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; @@ -44,6 +45,7 @@ export class IssueOverviewPanel extends W protected _folderRepositoryManager: FolderRepositoryManager; protected _scrollPosition = { x: 0, y: 0 }; private _identityUpdateSequence = 0; + protected readonly previewLog = { label: 'Issue', id: IssueOverviewPanel.ID }; protected static _getViewColumn(toTheSide: boolean, panel?: IssueOverviewPanel): number | undefined { const tabViewColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; @@ -63,8 +65,6 @@ export class IssueOverviewPanel extends W _preserveFocus: boolean = true, existingPanel?: vscode.WebviewPanel ) { - await ensureEmojis(folderRepositoryManager.context); - const key = panelKey(identity.owner, identity.repo, identity.number); let panel = this._panels.get(key); if (existingPanel && panel && panel._panel !== existingPanel) { @@ -324,6 +324,26 @@ export class IssueOverviewPanel extends W protected async updateItem(issueModel: TItem): Promise { try { + if (!this._item) { + this._postMessage({ + command: 'pr.preview', + pullrequest: { + number: issueModel.number, + title: issueModel.title, + titleHTML: issueModel.titleHTML, + url: issueModel.html_url, + body: issueModel.body, + bodyHTML: issueModel.bodyHTML, + author: issueModel.author, + createdAt: issueModel.createdAt, + state: issueModel.state, + stateReason: issueModel.stateReason, + isIssue: true, + } satisfies IssuePreview, + }); + Logger.debug('Issue overview preview loaded from model', IssueOverviewPanel.ID); + } + const [ issue, timelineEvents, @@ -342,6 +362,7 @@ export class IssueOverviewPanel extends W issueModel.canEdit(), this._folderRepositoryManager.getAssignableUsers(), this._folderRepositoryManager.getCurrentUser(), + ensureEmojis(this._folderRepositoryManager.context), ]); if (!issue) { @@ -390,12 +411,18 @@ export class IssueOverviewPanel extends W return 'issue'; } + protected getPreview(repository: GitHubRepository, number: number): Promise { + return repository.getIssuePreview(number); + } + /** * 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 | Promise, progressLocation?: string): Promise { const updateSequence = ++this._identityUpdateSequence; + let loading = true; + const isLoading = () => loading && !this.isDisposed && updateSequence === this._identityUpdateSequence; this._identity = identity; this._folderRepositoryManager = foldersManager; @@ -411,39 +438,69 @@ export class IssueOverviewPanel extends W } } - if (issueModel instanceof Promise) { - try { - issueModel = await issueModel; - } catch (error) { - if (updateSequence === this._identityUpdateSequence && !this._item) { - this.dispose(); + if (isLoading() && (!issueModel || issueModel instanceof Promise)) { + void (async () => { + try { + const start = Date.now(); + const repository = await foldersManager.createGitHubRepositoryFromOwnerName(identity.owner, identity.repo, false); + if (!isLoading()) { + return; + } + if (!repository) { + throw new Error(`Unable to find repository for ${identity.owner}/${identity.repo}`); + } + const preview = await this.getPreview(repository, identity.number); + if (isLoading()) { + await this._postMessage({ command: 'pr.preview', pullrequest: preview }); + Logger.debug(`${this.previewLog.label} overview preview loaded in ${Date.now() - start}ms`, this.previewLog.id); + } + } catch (error) { + Logger.error(`Unable to load ${this.previewLog.label} overview preview: ${formatError(error)}`, this.previewLog.id); } - throw error; - } - } - if (this.isDisposed || updateSequence !== this._identityUpdateSequence) { - return; + })(); } - // 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}`, - ); + try { + if (issueModel instanceof Promise) { + try { + issueModel = await issueModel; + } catch (error) { + if (updateSequence === this._identityUpdateSequence && !this._item) { + this.dispose(); + } + throw error; + } + } + if (this.isDisposed || updateSequence !== this._identityUpdateSequence) { + return; } - issueModel = resolvedModel; - } - if (this.isDisposed || updateSequence !== this._identityUpdateSequence) { - return; - } + // 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!)); - } else { - return this.updateItem(issueModel); + if (this.isDisposed || updateSequence !== this._identityUpdateSequence) { + return; + } + + if (issueModel instanceof Promise) { + throw new Error(`Failed to resolve ${this.getItemTypeName()} #${identity.number} in ${identity.owner}/${identity.repo}`); + } + const resolvedIssueModel = issueModel; + if (progressLocation) { + return await vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(resolvedIssueModel)); + } else { + return await this.updateItem(resolvedIssueModel); + } + } finally { + loading = false; } } diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 5aaa96a066..562f8fdd29 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -13,6 +13,7 @@ import { addAttestationCommit, isAttestationCommitsEnabled } from './attestation import { getCopilotApi } from './copilotApi'; import { SessionIdForPr } from './copilotRemoteAgent'; import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; import { getUpdatableStackEntries, GithubItemStateEnum, @@ -33,7 +34,7 @@ import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; import type { RepositoriesManager } from './repositoriesManager'; import { supportsStackGitOperations, updateStackBranches } from './updateStackBranches'; import { getIssueOrURLExpression, parseIssueExpressionOutput, parseReviewers, processDiffLinks, processPermalinks } from './utils'; -import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, UnresolvedIdentity, UnstackAllResult } from './views'; +import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, PullRequestPreview, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, UnresolvedIdentity, UnstackAllResult } 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'; @@ -56,6 +57,7 @@ import { getGitHubCommitFileSystemProvider } from '../view/githubFileContentProv export class PullRequestOverviewPanel extends IssueOverviewPanel { public static override ID: string = 'PullRequestOverviewPanel'; public static override readonly viewType = PULL_REQUEST_OVERVIEW_VIEW_TYPE; + protected override readonly previewLog = { label: 'PR', id: PullRequestOverviewPanel.ID }; /** * All open PR panels, keyed by "owner/repo#number". @@ -80,7 +82,6 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel | undefined; private _updateSequence = 0; - private _previewSequence = 0; private _stackLoaded = false; private _stackPullRequestNumbers = new Set(); private _stackRefreshPending = false; @@ -837,39 +838,17 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + return repository.getPullRequestPreview(number); + } + public override async updateWithIdentity( folderRepositoryManager: FolderRepositoryManager, identity: UnresolvedIdentity, pullRequestModel?: PullRequestModel | Promise, progressLocation?: string ): Promise { - const previewSequence = ++this._previewSequence; - let loading = true; - const isLoading = () => loading && !this.isDisposed && previewSequence === this._previewSequence; - const update = super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); - if (isLoading() && (!pullRequestModel || pullRequestModel instanceof Promise)) { - void (async () => { - try { - const start = Date.now(); - const repository = await folderRepositoryManager.createGitHubRepositoryFromOwnerName(identity.owner, identity.repo, false); - if (!repository || !isLoading()) { - return; - } - const preview = await repository.getPullRequestPreview(identity.number); - if (isLoading()) { - await this._postMessage({ command: 'pr.preview', pullrequest: preview }); - Logger.debug(`PR overview preview loaded in ${Date.now() - start}ms`, PullRequestOverviewPanel.ID); - } - } catch (error) { - Logger.error(`Unable to load PR overview preview: ${formatError(error)}`, PullRequestOverviewPanel.ID); - } - })(); - } - try { - await update; - } finally { - loading = false; - } + await super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); // Notify that this PR overview is now active if (!this.isDisposed && this._item) { diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql index ad9ea8e77c..02851b297f 100644 --- a/src/github/queriesShared.gql +++ b/src/github/queriesShared.gql @@ -1,1440 +1,1471 @@ -# /*--------------------------------------------------------------------------------------------- -# * Copyright (c) Microsoft Corporation. All rights reserved. -# * Licensed under the MIT License. See License.txt in the project root for license information. -# *--------------------------------------------------------------------------------------------*/ - -fragment Node on Node { - id -} - -fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. - __typename - login - avatarUrl - url -} - -fragment User on User { - __typename - ...Actor - email - name - ...Node -} - -fragment Organization on Organization { - __typename - ...Actor - email - name - ...Node -} - -fragment Team on Team { # Team is not an Actor - name - avatarUrl - url - slug - ...Node -} - -fragment RateLimit on RateLimit { - limit - cost - remaining - resetAt -} - -fragment Merged on MergedEvent { - id - actor { - ...Node - ...Actor - } - createdAt - mergeRef { - name - } - mergeRefName - commit { - oid - commitUrl - } - url -} - -fragment HeadRefDeleted on HeadRefDeletedEvent { - id - actor { - ...Node - ...Actor - } - createdAt - headRefName -} - -fragment Ref on Ref { - name - repository { - owner { - login - } - url - } - target { - oid - } -} - -fragment Comment on IssueComment { - id - databaseId - authorAssociation - author { - ...Node - ...Actor - ...User - ...Organization - } - url - body - bodyHTML - updatedAt - createdAt - viewerCanUpdate - viewerCanReact - viewerCanDelete - ...Reactable -} - -fragment Commit on PullRequestCommit { - id - commit { - author { - user { - ...Node - ...Actor - ...User - } - } - committer { - avatarUrl - name - } - oid - message - committedDate - signature { - isValid - state - wasSignedByGitHub - signer { - login - name - avatarUrl - } - ... on GpgSignature { - keyId - } - ... on SshSignature { - keyFingerprint - } - ... on SmimeSignature { - email - } - } - statusCheckRollup { - state - } - } - url -} - -fragment AssignedEvent on AssignedEvent { - id - actor { - ...Node - ...Actor - } - user { - ...Node - ...Actor - ...User - } - createdAt -} - -fragment UnassignedEvent on UnassignedEvent { - id - actor { - ...Node - ...Actor - } - user { - ...Node - ...Actor - ...User - } - createdAt -} - -fragment CrossReferencedEvent on CrossReferencedEvent { - id - actor { - ...Node - ...Actor - } - createdAt - source { - ... on PullRequest { - number - url - title - repository: baseRepository { - owner { - login - } - name - } - } - ... on Issue { - number - url - title - repository { - owner { - login - } - name - } - } - } - willCloseTarget -} - -fragment ClosedEvent on ClosedEvent { - id - actor { - ...Node - ...Actor - } - createdAt -} - -fragment ReopenedEvent on ReopenedEvent { - id - actor { - ...Node - ...Actor - } - createdAt -} - -fragment BaseRefChangedEvent on BaseRefChangedEvent { - id - actor { - ...Node - ...Actor - } - createdAt - currentRefName - previousRefName -} - -fragment Review on PullRequestReview { - id - databaseId - authorAssociation - url - author { - ...User - ...Organization - ...Node - ...Actor - } - state - body - bodyHTML - submittedAt - updatedAt - createdAt - ...Reactable -} - -fragment Reactable on Reactable { - reactionGroups { - content - viewerHasReacted - reactors(first: 10) { - nodes { - ... on User { - login - } - ... on Actor { - login - } - } - totalCount - } - } -} - - -fragment ReviewThread on PullRequestReviewThread { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - line - startLine - originalStartLine - originalLine - isOutdated - subjectType - comments(first: 100) { - nodes { - ...ReviewComment - } - } -} - -fragment LegacyReviewThread on PullRequestReviewThread { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - line - startLine - originalStartLine - originalLine - isOutdated - comments(first: 100) { - nodes { - ...ReviewComment - } - } -} - -fragment MergeQueueEntryFragment on MergeQueueEntry { - position - state - mergeQueue { - url - } -} - -query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Merged - ...Comment - ...Review - ...Commit - ...AssignedEvent - ...UnassignedEvent - ...HeadRefDeleted - ...CrossReferencedEvent - ...ClosedEvent - ...ReopenedEvent - ...BaseRefChangedEvent - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query PullRequestActivityTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 5) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Merged - ...Comment - ...Review - ...Commit - ...ClosedEvent - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Comment - ...AssignedEvent - ...UnassignedEvent - ...CrossReferencedEvent - ...ClosedEvent - ...ReopenedEvent - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query LatestReviewCommit($owner: String!, $name: String!, $number: Int!, $author: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviews(last: 1, author: $author, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED]) { - nodes { - commit { - oid - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query LatestReviews($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - latestReviews (first: 10) { - nodes { - state - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetOrganizationTeamsCount($login: String!) { - organization(login: $login) { - teams(first: 0, privacy: VISIBLE) { - totalCount - } - } - rateLimit { - ...RateLimit - } -} - -query GetOrganizationTeams($login: String!, $after: String, $repoName: String!) { - organization(login: $login) { - teams(first: 100, after: $after, privacy: VISIBLE) { - nodes { - ...Team - repositories(first: 5, query: $repoName) { - nodes { - name - } - } - } - totalCount - pageInfo { - hasNextPage - endCursor - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetReviewRequestsAdditionalScopes($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewRequests(first: 100) { - nodes { - requestedReviewer { - ...Node - ...Actor - ...User - ...Team - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetReviewRequests($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewRequests(first: 100) { - nodes { - requestedReviewer { - ...Node - ...Actor - ...User - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -fragment ReviewComment on PullRequestReviewComment { - id - databaseId - url - author { - ...Actor - ...Node - ...User - ...Organization - } - path - originalPosition - body - bodyHTML - diffHunk - position - state - pullRequestReview { - databaseId - } - commit { - oid - } - replyTo { - databaseId - } - createdAt - originalCommit { - oid - } - ...Reactable - viewerCanUpdate - viewerCanDelete -} - -query GetParticipants($owner: String!, $name: String!, $number: Int!, $first: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - participants(first: $first) { - nodes { - ...Node - ...Actor - ...User - ...Organization - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetPendingReviewId($pullRequestId: ID!, $author: String!) { - node(id: $pullRequestId) { - ... on PullRequest { - reviews(first: 1, author: $author, states: [PENDING]) { - nodes { - id - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query PullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: $first, after: $after) { - nodes { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - startLine - line - originalStartLine - originalLine - isOutdated - subjectType - comments(first: 100) { - edges { - node { - pullRequestReview { - databaseId - } - } - } - nodes { - ...ReviewComment - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query LegacyPullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: $first, after: $after) { - nodes { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - startLine - line - originalStartLine - originalLine - isOutdated - comments(first: 100) { - edges { - node { - pullRequestReview { - databaseId - } - } - } - nodes { - ...ReviewComment - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query Viewer { - viewer { - ...User - } - rateLimit { - ...RateLimit - } -} - -query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - files(first: 100, after: $after) { - nodes { - path - viewerViewedState - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetUser($login: String!) { - user(login: $login) { - login - avatarUrl(size: 50) - id - bio - name - company - location - contributionsCollection { - commitContributionsByRepository(maxRepositories: 50) { - contributions(first: 1) { - nodes { - occurredAt - } - } - repository { - nameWithOwner - } - } - } - url - } - rateLimit { - ...RateLimit - } -} - -query PullRequestPreview($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - number - title - titleHTML - url - body - bodyHTML - createdAt - state - isDraft - author { - __typename - login - url - avatarUrl - ... on Node { - id - } - ... on User { - name - } - } - baseRefName - headRefName - baseRepository { - owner { - login - } - } - headRepository { - owner { - login - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - mergeable - mergeStateStatus - } - } - rateLimit { - ...RateLimit - } -} - -query PullRequestStack($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - stackEntry { - position - } - stack { - size - baseRefName - entries(first: 100, after: $after) { - nodes { - position - pullRequest { - number - title - url - state - isDraft - baseRefName - baseRepository { - owner { - login - } - } - headRefName - headRepository { - owner { - login - } - } - mergeable - mergeStateStatus - mergeQueueEntry { - state - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } -} - -query PullRequestState($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - title - number - state - } - } - rateLimit { - ...RateLimit - } -} - -query PullRequestTemplates($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - pullRequestTemplates { - body - } - } -} - -fragment PullRequestNumberFragment on PullRequest { - number - title - author { - login - } -} - -query PullRequestNumbers($owner: String!, $name: String!, $first: Int!) { - repository(owner: $owner, name: $name) { - pullRequests(first: $first, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { - ...PullRequestNumberFragment - } - } - } - rateLimit { - ...RateLimit - } -} - -mutation AddComment($input: AddPullRequestReviewCommentInput!) { - addPullRequestReviewComment(input: $input) { - comment { - ...ReviewComment - } - } -} - -mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { - addPullRequestReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation LegacyAddReviewThread($input: AddPullRequestReviewThreadInput!) { - addPullRequestReviewThread(input: $input) { - thread { - ...LegacyReviewThread - } - } -} - -mutation AddReviewers($input: RequestReviewsInput!) { - requestReviews(input: $input) { - pullRequest { - id - } - } -} - -mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { - updatePullRequestReviewComment(input: $input) { - pullRequestReviewComment { - ...ReviewComment - } - } -} - -mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { - markPullRequestReadyForReview(input: $input) { - pullRequest { - isDraft - mergeable - mergeStateStatus - viewerCanEnableAutoMerge - viewerCanDisableAutoMerge - } - } -} - -mutation ConvertToDraft($input: ConvertPullRequestToDraftInput!) { - convertPullRequestToDraft(input: $input) { - pullRequest { - isDraft - mergeable - mergeStateStatus - } - } -} - -mutation StartReview($input: AddPullRequestReviewInput!) { - addPullRequestReview(input: $input) { - pullRequestReview { - id - } - } -} - -mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { - submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { - pullRequestReview { - comments(first: 100) { - nodes { - ...ReviewComment - } - } - ...Review - } - } -} - -mutation DeleteReview($input: DeletePullRequestReviewInput!) { - deletePullRequestReview(input: $input) { - pullRequestReview { - databaseId - comments(first: 100) { - nodes { - ...ReviewComment - } - } - } - } -} - -mutation AddReaction($input: AddReactionInput!) { - addReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} - -mutation DeleteReaction($input: RemoveReactionInput!) { - removeReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} - -mutation UpdateIssue($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { - body - bodyHTML - title - titleHTML - milestone { - title - dueOn - createdAt - id - number - } - } - } -} - -mutation UpdatePullRequest($input: UpdatePullRequestInput!) { - updateIssue: updatePullRequest(input: $input) { - issue: pullRequest { - body - bodyHTML - title - titleHTML - milestone { - title - dueOn - createdAt - id - number - } - } - } -} - -mutation AddIssueComment($input: AddCommentInput!) { - addComment(input: $input) { - commentEdge { - node { - ...Comment - } - } - } -} - -mutation EditIssueComment($input: UpdateIssueCommentInput!) { - updateIssueComment(input: $input) { - issueComment { - ...Comment - } - } -} - -query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - mentionableUsers(first: $first, after: $after) { - nodes { - ...Node - ...Actor - ...User - } - pageInfo { - hasNextPage - endCursor - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetBranch($owner: String!, $name: String!, $qualifiedName: String!) { - repository(owner: $owner, name: $name) { - ref(qualifiedName: $qualifiedName) { - target { - oid - } - } - } - rateLimit { - ...RateLimit - } -} - -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { - repository(owner: $owner, name: $name) { - refs(first: $first, after: $after, refPrefix: "refs/heads/", query: $query) { - nodes { - name - } - pageInfo { - hasNextPage - endCursor - } - } - } - rateLimit { - ...RateLimit - } -} - -query MaxIssue($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { - edges { - node { - ... on Issue { - number - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query MaxPullRequest($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - issues: pullRequests(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { - edges { - node { - ... on PullRequest { - number - } - } - } - } - } -} - -query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { - repository(owner: $owner, name: $name) { - milestones(first: 100, orderBy: { direction: DESC, field: DUE_DATE }, states: $states) { - nodes { - dueOn - title - createdAt - id - number - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetViewerPermission($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - viewerPermission - } - rateLimit { - ...RateLimit - } -} - -query GetRepositoryForkDetails($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - isFork - parent { - name - owner { - login - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetChecks($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - url - latestReviews (first: 10) { - nodes { - authorAssociation - authorCanPushToRepository - state - author { - login - } - } - } - reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { - nodes { - authorAssociation - authorCanPushToRepository - state - author { - login - } - } - } - baseRef { - refUpdateRule { - requiredApprovingReviewCount - requiredStatusCheckContexts - requiresCodeOwnerReviews - viewerCanPush - } - } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - state - contexts(first: 100) { - nodes { - __typename - ... on StatusContext { - id - state - targetUrl - description - context - avatarUrl - isRequired(pullRequestNumber: $number) - } - ... on CheckRun { - id - databaseId - conclusion - title - detailsUrl - name - resourcePath - isRequired(pullRequestNumber: $number) - checkSuite { - app { - logoUrl - url - } - workflowRun { - event - workflow { - name - } - } - } - } - } - } - } - checkSuites(first: 100) { - nodes { - status - conclusion - workflowRun { - event - workflow { - name - } - } - app { - logoUrl - url - } - } - } - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - url - latestReviews (first: 10) { - nodes { - authorAssociation - authorCanPushToRepository - state - author { - login - } - } - } - reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { - nodes { - authorAssociation - authorCanPushToRepository - state - author { - login - } - } - } - baseRef { - refUpdateRule { - requiredApprovingReviewCount - requiredStatusCheckContexts - requiresCodeOwnerReviews - viewerCanPush - } - } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - state - contexts(first: 100) { - nodes { - __typename - ... on StatusContext { - id - state - targetUrl - description - context - avatarUrl - isRequired(pullRequestNumber: $number) - } - ... on CheckRun { - id - databaseId - conclusion - title - detailsUrl - name - resourcePath - isRequired(pullRequestNumber: $number) - } - } - } - } - checkSuites(first: 100) { - nodes { - status - conclusion - workflowRun { - event - workflow { - name - } - } - } - } - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query MergeQueueForBranch($owner: String!, $name: String!, $branch: String!) { - repository(owner: $owner, name: $name) { - mergeQueue(branch: $branch) { - configuration { - mergeMethod - } - } - } -} - -query GetFileContent($owner: String!, $name: String!, $expression: String!) { - repository(owner: $owner, name: $name) { - object(expression: $expression) { - ... on Blob { - text - } - } - } - rateLimit { - ...RateLimit - } -} - -mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { - resolveReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation LegacyResolveReviewThread($input: ResolveReviewThreadInput!) { - resolveReviewThread(input: $input) { - thread { - ...LegacyReviewThread - } - } -} - -mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { - unresolveReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation LegacyUnresolveReviewThread($input: UnresolveReviewThreadInput!) { - unresolveReviewThread(input: $input) { - thread { - ...LegacyReviewThread - } - } -} - -mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { - enablePullRequestAutoMerge(input: $input) { - pullRequest { - id - } - } -} - -mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { - disablePullRequestAutoMerge(input: $input) { - pullRequest { - id - } - } -} - -mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { - markFileAsViewed(input: $input) { - pullRequest { - id - } - } -} - -mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { - unmarkFileAsViewed(input: $input) { - pullRequest { - id - } - } -} - -mutation MergePullRequest($input: MergePullRequestInput!, $last: Int = 150) { - mergePullRequest(input: $input) { - pullRequest { - id - timelineItems(last: $last) { - nodes { - __typename - ...Merged - ...Comment - ...Review - ...Commit - ...AssignedEvent - ...UnassignedEvent - ...HeadRefDeleted - ...CrossReferencedEvent - ...ClosedEvent - ...ReopenedEvent - ...BaseRefChangedEvent - } - } - } - } -} +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +fragment Node on Node { + id +} + +fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. + __typename + login + avatarUrl + url +} + +fragment User on User { + __typename + ...Actor + email + name + ...Node +} + +fragment Organization on Organization { + __typename + ...Actor + email + name + ...Node +} + +fragment Team on Team { # Team is not an Actor + name + avatarUrl + url + slug + ...Node +} + +fragment RateLimit on RateLimit { + limit + cost + remaining + resetAt +} + +fragment Merged on MergedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + mergeRef { + name + } + mergeRefName + commit { + oid + commitUrl + } + url +} + +fragment HeadRefDeleted on HeadRefDeletedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + headRefName +} + +fragment Ref on Ref { + name + repository { + owner { + login + } + url + } + target { + oid + } +} + +fragment Comment on IssueComment { + id + databaseId + authorAssociation + author { + ...Node + ...Actor + ...User + ...Organization + } + url + body + bodyHTML + updatedAt + createdAt + viewerCanUpdate + viewerCanReact + viewerCanDelete + ...Reactable +} + +fragment Commit on PullRequestCommit { + id + commit { + author { + user { + ...Node + ...Actor + ...User + } + } + committer { + avatarUrl + name + } + oid + message + committedDate + signature { + isValid + state + wasSignedByGitHub + signer { + login + name + avatarUrl + } + ... on GpgSignature { + keyId + } + ... on SshSignature { + keyFingerprint + } + ... on SmimeSignature { + email + } + } + statusCheckRollup { + state + } + } + url +} + +fragment AssignedEvent on AssignedEvent { + id + actor { + ...Node + ...Actor + } + user { + ...Node + ...Actor + ...User + } + createdAt +} + +fragment UnassignedEvent on UnassignedEvent { + id + actor { + ...Node + ...Actor + } + user { + ...Node + ...Actor + ...User + } + createdAt +} + +fragment CrossReferencedEvent on CrossReferencedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + source { + ... on PullRequest { + number + url + title + repository: baseRepository { + owner { + login + } + name + } + } + ... on Issue { + number + url + title + repository { + owner { + login + } + name + } + } + } + willCloseTarget +} + +fragment ClosedEvent on ClosedEvent { + id + actor { + ...Node + ...Actor + } + createdAt +} + +fragment ReopenedEvent on ReopenedEvent { + id + actor { + ...Node + ...Actor + } + createdAt +} + +fragment BaseRefChangedEvent on BaseRefChangedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + currentRefName + previousRefName +} + +fragment Review on PullRequestReview { + id + databaseId + authorAssociation + url + author { + ...User + ...Organization + ...Node + ...Actor + } + state + body + bodyHTML + submittedAt + updatedAt + createdAt + ...Reactable +} + +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + ... on Actor { + login + } + } + totalCount + } + } +} + + +fragment ReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment LegacyReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment MergeQueueEntryFragment on MergeQueueEntry { + position + state + mergeQueue { + url + } +} + +query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...AssignedEvent + ...UnassignedEvent + ...HeadRefDeleted + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + ...BaseRefChangedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestActivityTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 5) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...ClosedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Comment + ...AssignedEvent + ...UnassignedEvent + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviewCommit($owner: String!, $name: String!, $number: Int!, $author: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviews(last: 1, author: $author, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED]) { + nodes { + commit { + oid + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviews($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + latestReviews (first: 10) { + nodes { + state + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeamsCount($login: String!) { + organization(login: $login) { + teams(first: 0, privacy: VISIBLE) { + totalCount + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeams($login: String!, $after: String, $repoName: String!) { + organization(login: $login) { + teams(first: 100, after: $after, privacy: VISIBLE) { + nodes { + ...Team + repositories(first: 5, query: $repoName) { + nodes { + name + } + } + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequestsAdditionalScopes($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ...Node + ...Actor + ...User + ...Team + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequests($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ...Node + ...Actor + ...User + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +fragment ReviewComment on PullRequestReviewComment { + id + databaseId + url + author { + ...Actor + ...Node + ...User + ...Organization + } + path + originalPosition + body + bodyHTML + diffHunk + position + state + pullRequestReview { + databaseId + } + commit { + oid + } + replyTo { + databaseId + } + createdAt + originalCommit { + oid + } + ...Reactable + viewerCanUpdate + viewerCanDelete +} + +query GetParticipants($owner: String!, $name: String!, $number: Int!, $first: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + participants(first: $first) { + nodes { + ...Node + ...Actor + ...User + ...Organization + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetPendingReviewId($pullRequestId: ID!, $author: String!) { + node(id: $pullRequestId) { + ... on PullRequest { + reviews(first: 1, author: $author, states: [PENDING]) { + nodes { + id + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: $first, after: $after) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LegacyPullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: $first, after: $after) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query Viewer { + viewer { + ...User + } + rateLimit { + ...RateLimit + } +} + +query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + nodes { + path + viewerViewedState + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetUser($login: String!) { + user(login: $login) { + login + avatarUrl(size: 50) + id + bio + name + company + location + contributionsCollection { + commitContributionsByRepository(maxRepositories: 50) { + contributions(first: 1) { + nodes { + occurredAt + } + } + repository { + nameWithOwner + } + } + } + url + } + rateLimit { + ...RateLimit + } +} + +query IssuePreview($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + number + title + titleHTML + url + body + bodyHTML + createdAt + state + stateReason + author { + __typename + login + url + avatarUrl + ... on Node { + id + } + ... on User { + name + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestPreview($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number + title + titleHTML + url + body + bodyHTML + createdAt + state + isDraft + author { + __typename + login + url + avatarUrl + ... on Node { + id + } + ... on User { + name + } + } + baseRefName + headRefName + baseRepository { + owner { + login + } + } + headRepository { + owner { + login + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestStack($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + stackEntry { + position + } + stack { + size + baseRefName + entries(first: 100, after: $after) { + nodes { + position + pullRequest { + number + title + url + state + isDraft + baseRefName + baseRepository { + owner { + login + } + } + headRefName + headRepository { + owner { + login + } + } + mergeable + mergeStateStatus + mergeQueueEntry { + state + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } +} + +query PullRequestState($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + title + number + state + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestTemplates($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequestTemplates { + body + } + } +} + +fragment PullRequestNumberFragment on PullRequest { + number + title + author { + login + } +} + +query PullRequestNumbers($owner: String!, $name: String!, $first: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(first: $first, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestNumberFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation AddComment($input: AddPullRequestReviewCommentInput!) { + addPullRequestReviewComment(input: $input) { + comment { + ...ReviewComment + } + } +} + +mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyAddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation AddReviewers($input: RequestReviewsInput!) { + requestReviews(input: $input) { + pullRequest { + id + } + } +} + +mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { + updatePullRequestReviewComment(input: $input) { + pullRequestReviewComment { + ...ReviewComment + } + } +} + +mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { + markPullRequestReadyForReview(input: $input) { + pullRequest { + isDraft + mergeable + mergeStateStatus + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + } + } +} + +mutation ConvertToDraft($input: ConvertPullRequestToDraftInput!) { + convertPullRequestToDraft(input: $input) { + pullRequest { + isDraft + mergeable + mergeStateStatus + } + } +} + +mutation StartReview($input: AddPullRequestReviewInput!) { + addPullRequestReview(input: $input) { + pullRequestReview { + id + } + } +} + +mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { + pullRequestReview { + comments(first: 100) { + nodes { + ...ReviewComment + } + } + ...Review + } + } +} + +mutation DeleteReview($input: DeletePullRequestReviewInput!) { + deletePullRequestReview(input: $input) { + pullRequestReview { + databaseId + comments(first: 100) { + nodes { + ...ReviewComment + } + } + } + } +} + +mutation AddReaction($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation DeleteReaction($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation UpdateIssue($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + body + bodyHTML + title + titleHTML + milestone { + title + dueOn + createdAt + id + number + } + } + } +} + +mutation UpdatePullRequest($input: UpdatePullRequestInput!) { + updateIssue: updatePullRequest(input: $input) { + issue: pullRequest { + body + bodyHTML + title + titleHTML + milestone { + title + dueOn + createdAt + id + number + } + } + } +} + +mutation AddIssueComment($input: AddCommentInput!) { + addComment(input: $input) { + commentEdge { + node { + ...Comment + } + } + } +} + +mutation EditIssueComment($input: UpdateIssueCommentInput!) { + updateIssueComment(input: $input) { + issueComment { + ...Comment + } + } +} + +query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: $first, after: $after) { + nodes { + ...Node + ...Actor + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetBranch($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualifiedName) { + target { + oid + } + } + } + rateLimit { + ...RateLimit + } +} + +query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { + repository(owner: $owner, name: $name) { + refs(first: $first, after: $after, refPrefix: "refs/heads/", query: $query) { + nodes { + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query MaxIssue($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { + edges { + node { + ... on Issue { + number + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query MaxPullRequest($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues: pullRequests(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { + edges { + node { + ... on PullRequest { + number + } + } + } + } + } +} + +query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { + repository(owner: $owner, name: $name) { + milestones(first: 100, orderBy: { direction: DESC, field: DUE_DATE }, states: $states) { + nodes { + dueOn + title + createdAt + id + number + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetViewerPermission($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + viewerPermission + } + rateLimit { + ...RateLimit + } +} + +query GetRepositoryForkDetails($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + isFork + parent { + name + owner { + login + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecks($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + databaseId + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + checkSuite { + app { + logoUrl + url + } + workflowRun { + event + workflow { + name + } + } + } + } + } + } + } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + app { + logoUrl + url + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + databaseId + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + } + } + } + } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query MergeQueueForBranch($owner: String!, $name: String!, $branch: String!) { + repository(owner: $owner, name: $name) { + mergeQueue(branch: $branch) { + configuration { + mergeMethod + } + } + } +} + +query GetFileContent($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + object(expression: $expression) { + ... on Blob { + text + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyUnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { + markFileAsViewed(input: $input) { + pullRequest { + id + } + } +} + +mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { + unmarkFileAsViewed(input: $input) { + pullRequest { + id + } + } +} + +mutation MergePullRequest($input: MergePullRequestInput!, $last: Int = 150) { + mergePullRequest(input: $input) { + pullRequest { + id + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...AssignedEvent + ...UnassignedEvent + ...HeadRefDeleted + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + ...BaseRefChangedEvent + } + } + } + } +} diff --git a/src/github/views.ts b/src/github/views.ts index 9795b2ac75..d73f964ea6 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -1,301 +1,307 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - GithubItemStateEnum, - IAccount, - ILabel, - IMilestone, - IProjectItem, - IssueReference, - MergeMethod, - MergeMethodsAvailability, - MergeQueueState, - PullRequestChecks, - PullRequestMergeability, - PullRequestReviewRequirement, - PullRequestStack, - Reaction, - ReviewState, - StateReason, -} from './interface'; -import type { StackMergeOutcome } from './pullRequestModel'; -import { IComment } from '../common/comment'; -import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; - -export enum ReviewType { - Comment = 'comment', - Approve = 'approve', - RequestChanges = 'requestChanges', -} - -export interface DisplayLabel extends ILabel { - displayName: string; -} - -export interface Issue { - owner: string; - repo: string; - number: number; - title: string; - titleHTML: string; - url: string; - createdAt: string; - body: string; - bodyHTML?: string; - author: IAccount; - state: GithubItemStateEnum; // TODO: don't allow merged - stateReason?: StateReason; - events: TimelineEvent[]; - labels: DisplayLabel[]; - assignees: IAccount[]; - projectItems: IProjectItem[] | undefined; - milestone: IMilestone | undefined; - /** - * User can edit PR title and description (author or user with push access) - */ - canEdit: boolean; - /** - * Users with push access to repo have rights to merge/close PRs, - * edit title/description, assign reviewers/labels etc. - */ - hasWritePermission: boolean; - pendingCommentText?: string; - pendingCommentDrafts?: { [key: string]: string }; - isIssue: boolean; - isAuthor: boolean; - continueOnGitHub: boolean; - isDarkTheme: boolean; - isEnterprise: boolean; - canAssignCopilot: boolean; - canRequestCopilotReview: boolean; - reactions: Reaction[]; - busy?: boolean; -} - -/** Read-only content shown during a cold load; never persisted as a complete PR. */ -export type PullRequestPreview = Pick; - -export interface PullRequest extends Issue { - stack?: PullRequestStack; - canUpdateStack?: boolean; - stackLoaded?: boolean; - stackLoadError?: boolean; - stackMergeStatus?: 'pending' | 'enqueued'; - isCopilotOnMyBehalf: boolean; - isAgentSessionsWorkspace: boolean; - isCurrentlyCheckedOut: boolean; - isRemoteBaseDeleted?: boolean; - base: string; - isRemoteHeadDeleted?: boolean; - isLocalHeadDeleted?: boolean; - head: string; - commitsCount: number; - projectItems: IProjectItem[] | undefined; - repositoryDefaultBranch: string; - doneCheckoutBranch: string; - emailForCommit?: string; - pendingReviewType?: ReviewType; - pendingReviewSummaryText?: string; - status: PullRequestChecks | null; - reviewRequirement: PullRequestReviewRequirement | null; - canUpdateBranch: boolean; - mergeable: PullRequestMergeability; - defaultMergeMethod: MergeMethod; - mergeMethodsAvailability: MergeMethodsAvailability; - autoMerge?: boolean; - allowAutoMerge: boolean; - autoMergeMethod?: MergeMethod; - mergeQueueMethod: MergeMethod | undefined; - mergeQueueEntry?: { - url: string; - position: number; - state: MergeQueueState; - }; - mergeCommitMeta?: { title: string, description: string }; - squashCommitMeta?: { title: string, description: string }; - reviewers: ReviewState[]; - isDraft?: boolean; - currentUserReviewState?: string; - hasReviewDraft: boolean; - lastReviewType?: ReviewType; - revertable?: boolean; - busy?: boolean; - loadingCommit?: string; - generateDescriptionTitle?: string; - closingIssues?: IssueReference[]; - attestationCommitsEnabled?: boolean; -} - -export interface ProjectItemsReply { - projectItems: IProjectItem[] | undefined; -} - -export interface ChangeAssigneesReply { - assignees: IAccount[]; - events: TimelineEvent[]; -} - -export interface ChangeReviewersReply { - reviewers: ReviewState[]; -} - -export interface SubmitReviewReply { - events?: TimelineEvent[]; - /** - * Extra timeline events (e.g. a locally-constructed attestation commit) that the - * webview should append to its existing timeline immediately before the - * `reviewedEvent`. Lets callers avoid an extra `getTimelineEvents` round-trip when - * they already know what changed. - */ - additionalEvents?: TimelineEvent[]; - reviewedEvent: ReviewEvent | CommentEvent; - reviewers?: ReviewState[]; -} - -export interface SubmitReviewArgs { - body: string; - addAttestation?: boolean; -} - -export interface ReadyForReviewReply { - isDraft: boolean; - reviewEvent?: ReviewEvent; - reviewers?: ReviewState[]; - autoMerge?: boolean; -} - -export interface ConvertToDraftReply { - isDraft: boolean; -} - -export interface MergeArguments { - title: string | undefined; - description: string | undefined; - method: MergeMethod; - email?: string; -} - -export interface MergeResult { - state: GithubItemStateEnum; - revertable: boolean; - events?: TimelineEvent[]; -} - -export interface StackMergeResult { - status: StackMergeOutcome; - state?: GithubItemStateEnum; -} - -export interface UnstackAllResult { - cancelled: boolean; - remainingPullRequests?: number[]; -} - -export interface UpdateStackResult { - updatedPullRequests: number[]; -} - -export interface DeleteReviewResult { - deletedReviewId: number; - deletedReviewComments: IComment[]; -} - -export enum PreReviewState { - None = 0, - Available, - ReviewedWithComments, - ReviewedWithoutComments -} - -export interface ChangeTemplateReply { - description: string; -} - -export interface CancelCodingAgentReply { - events: TimelineEvent[]; -} - -export interface FileUploadPlaceholder { - name: string; - placeholder: string; -} - -export interface UploadFilesReply { - uploads: FileUploadPlaceholder[]; -} - -export interface UploadPastedFilesArgs { - files: { name: string; type: string; bytesBase64: string }[]; -} - -export interface FileUploadCompletedMessage { - command: 'pr.file-upload-completed'; - name: string; - placeholder: string; - markdown?: string; - error?: string; -} - -export interface BaseContext { - 'preventDefaultContextMenuItems': true; - owner: string; - repo: string; - number: number; - url?: string; - [key: string]: boolean | string | number | undefined; -} - -export interface OverviewContext extends BaseContext { - 'github:checkoutMenu': true; -} - -export interface ReadyForReviewContext extends BaseContext { - 'github:readyForReviewMenu': true; -} - -export interface ReadyForReviewAndMergeContext extends ReadyForReviewContext { - 'github:readyForReviewMenuWithMerge': true; - mergeMethod: MergeMethod; -} - -export interface CodingAgentContext extends SessionLinkInfo { - 'preventDefaultContextMenuItems': true; - 'github:codingAgentMenu': true; - [key: string]: boolean | string | number | undefined; -} - -export interface ReviewCommentContext { - 'preventDefaultContextMenuItems': true; - 'github:reviewCommentMenu': true, - owner: string; - repo: string; - number: number; - body: string; - addAttestation?: boolean; - 'github:reviewCommentApprove'?: boolean; - 'github:reviewCommentApproveOnDotCom'?: boolean; - 'github:reviewCommentComment'?: boolean; - 'github:reviewCommentCommentEnabled'?: boolean; - 'github:reviewCommentRequestChanges'?: boolean; - 'github:reviewRequestChangesEnabled'?: boolean; - 'github:reviewCommentRequestChangesOnDotCom'?: boolean; -} - -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; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + GithubItemStateEnum, + IAccount, + ILabel, + IMilestone, + IProjectItem, + IssueReference, + MergeMethod, + MergeMethodsAvailability, + MergeQueueState, + PullRequestChecks, + PullRequestMergeability, + PullRequestReviewRequirement, + PullRequestStack, + Reaction, + ReviewState, + StateReason, +} from './interface'; +import type { StackMergeOutcome } from './pullRequestModel'; +import { IComment } from '../common/comment'; +import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; + +export enum ReviewType { + Comment = 'comment', + Approve = 'approve', + RequestChanges = 'requestChanges', +} + +export interface DisplayLabel extends ILabel { + displayName: string; +} + +export interface Issue { + owner: string; + repo: string; + number: number; + title: string; + titleHTML: string; + url: string; + createdAt: string; + body: string; + bodyHTML?: string; + author: IAccount; + state: GithubItemStateEnum; // TODO: don't allow merged + stateReason?: StateReason; + events: TimelineEvent[]; + labels: DisplayLabel[]; + assignees: IAccount[]; + projectItems: IProjectItem[] | undefined; + milestone: IMilestone | undefined; + /** + * User can edit PR title and description (author or user with push access) + */ + canEdit: boolean; + /** + * Users with push access to repo have rights to merge/close PRs, + * edit title/description, assign reviewers/labels etc. + */ + hasWritePermission: boolean; + pendingCommentText?: string; + pendingCommentDrafts?: { [key: string]: string }; + isIssue: boolean; + isAuthor: boolean; + continueOnGitHub: boolean; + isDarkTheme: boolean; + isEnterprise: boolean; + canAssignCopilot: boolean; + canRequestCopilotReview: boolean; + reactions: Reaction[]; + busy?: boolean; +} + +/** Read-only content shown during a cold load; never persisted as a complete issue. */ +export type IssuePreview = Pick & { isIssue: true }; + +/** Read-only content shown during a cold load; never persisted as a complete PR. */ +export type PullRequestPreview = Pick & { isIssue?: false; stateReason?: undefined }; + +export type OverviewItemPreview = IssuePreview | PullRequestPreview; + +export interface PullRequest extends Issue { + stack?: PullRequestStack; + canUpdateStack?: boolean; + stackLoaded?: boolean; + stackLoadError?: boolean; + stackMergeStatus?: 'pending' | 'enqueued'; + isCopilotOnMyBehalf: boolean; + isAgentSessionsWorkspace: boolean; + isCurrentlyCheckedOut: boolean; + isRemoteBaseDeleted?: boolean; + base: string; + isRemoteHeadDeleted?: boolean; + isLocalHeadDeleted?: boolean; + head: string; + commitsCount: number; + projectItems: IProjectItem[] | undefined; + repositoryDefaultBranch: string; + doneCheckoutBranch: string; + emailForCommit?: string; + pendingReviewType?: ReviewType; + pendingReviewSummaryText?: string; + status: PullRequestChecks | null; + reviewRequirement: PullRequestReviewRequirement | null; + canUpdateBranch: boolean; + mergeable: PullRequestMergeability; + defaultMergeMethod: MergeMethod; + mergeMethodsAvailability: MergeMethodsAvailability; + autoMerge?: boolean; + allowAutoMerge: boolean; + autoMergeMethod?: MergeMethod; + mergeQueueMethod: MergeMethod | undefined; + mergeQueueEntry?: { + url: string; + position: number; + state: MergeQueueState; + }; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; + reviewers: ReviewState[]; + isDraft?: boolean; + currentUserReviewState?: string; + hasReviewDraft: boolean; + lastReviewType?: ReviewType; + revertable?: boolean; + busy?: boolean; + loadingCommit?: string; + generateDescriptionTitle?: string; + closingIssues?: IssueReference[]; + attestationCommitsEnabled?: boolean; +} + +export interface ProjectItemsReply { + projectItems: IProjectItem[] | undefined; +} + +export interface ChangeAssigneesReply { + assignees: IAccount[]; + events: TimelineEvent[]; +} + +export interface ChangeReviewersReply { + reviewers: ReviewState[]; +} + +export interface SubmitReviewReply { + events?: TimelineEvent[]; + /** + * Extra timeline events (e.g. a locally-constructed attestation commit) that the + * webview should append to its existing timeline immediately before the + * `reviewedEvent`. Lets callers avoid an extra `getTimelineEvents` round-trip when + * they already know what changed. + */ + additionalEvents?: TimelineEvent[]; + reviewedEvent: ReviewEvent | CommentEvent; + reviewers?: ReviewState[]; +} + +export interface SubmitReviewArgs { + body: string; + addAttestation?: boolean; +} + +export interface ReadyForReviewReply { + isDraft: boolean; + reviewEvent?: ReviewEvent; + reviewers?: ReviewState[]; + autoMerge?: boolean; +} + +export interface ConvertToDraftReply { + isDraft: boolean; +} + +export interface MergeArguments { + title: string | undefined; + description: string | undefined; + method: MergeMethod; + email?: string; +} + +export interface MergeResult { + state: GithubItemStateEnum; + revertable: boolean; + events?: TimelineEvent[]; +} + +export interface StackMergeResult { + status: StackMergeOutcome; + state?: GithubItemStateEnum; +} + +export interface UnstackAllResult { + cancelled: boolean; + remainingPullRequests?: number[]; +} + +export interface UpdateStackResult { + updatedPullRequests: number[]; +} + +export interface DeleteReviewResult { + deletedReviewId: number; + deletedReviewComments: IComment[]; +} + +export enum PreReviewState { + None = 0, + Available, + ReviewedWithComments, + ReviewedWithoutComments +} + +export interface ChangeTemplateReply { + description: string; +} + +export interface CancelCodingAgentReply { + events: TimelineEvent[]; +} + +export interface FileUploadPlaceholder { + name: string; + placeholder: string; +} + +export interface UploadFilesReply { + uploads: FileUploadPlaceholder[]; +} + +export interface UploadPastedFilesArgs { + files: { name: string; type: string; bytesBase64: string }[]; +} + +export interface FileUploadCompletedMessage { + command: 'pr.file-upload-completed'; + name: string; + placeholder: string; + markdown?: string; + error?: string; +} + +export interface BaseContext { + 'preventDefaultContextMenuItems': true; + owner: string; + repo: string; + number: number; + url?: string; + [key: string]: boolean | string | number | undefined; +} + +export interface OverviewContext extends BaseContext { + 'github:checkoutMenu': true; +} + +export interface ReadyForReviewContext extends BaseContext { + 'github:readyForReviewMenu': true; +} + +export interface ReadyForReviewAndMergeContext extends ReadyForReviewContext { + 'github:readyForReviewMenuWithMerge': true; + mergeMethod: MergeMethod; +} + +export interface CodingAgentContext extends SessionLinkInfo { + 'preventDefaultContextMenuItems': true; + 'github:codingAgentMenu': true; + [key: string]: boolean | string | number | undefined; +} + +export interface ReviewCommentContext { + 'preventDefaultContextMenuItems': true; + 'github:reviewCommentMenu': true, + owner: string; + repo: string; + number: number; + body: string; + addAttestation?: boolean; + 'github:reviewCommentApprove'?: boolean; + 'github:reviewCommentApproveOnDotCom'?: boolean; + 'github:reviewCommentComment'?: boolean; + 'github:reviewCommentCommentEnabled'?: boolean; + 'github:reviewCommentRequestChanges'?: boolean; + 'github:reviewRequestChangesEnabled'?: boolean; + 'github:reviewCommentRequestChangesOnDotCom'?: boolean; +} + +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/test/github/externalUriOpener.test.ts b/src/test/github/externalUriOpener.test.ts index c769541d56..5d8ce1905d 100644 --- a/src/test/github/externalUriOpener.test.ts +++ b/src/test/github/externalUriOpener.test.ts @@ -11,8 +11,8 @@ import { CredentialStore } from '../../github/credentials'; import { registerGitHubIssueOrPullRequestExternalUriOpener } from '../../github/externalUriOpener'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { FolderRepositoryManagerResolver } from '../../github/folderRepositoryManagerResolver'; +import { IssueOverviewPanel } from '../../github/issueOverview'; import { RepositoriesManager } from '../../github/repositoriesManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { MockTelemetry } from '../mocks/mockTelemetry'; @@ -41,11 +41,14 @@ describe('GitHubIssueOrPullRequestExternalUriOpener', () => { opener = value; return new vscode.Disposable(() => undefined); }); - const resolveIssue = sandbox.stub(FolderRepositoryManager.prototype, 'resolveIssue').callsFake(async function (this: FolderRepositoryManager) { + let finishResolveIssue!: (issue: undefined) => void; + const pendingIssue = new Promise(resolve => finishResolveIssue = resolve); + const resolveIssue = sandbox.stub(FolderRepositoryManager.prototype, 'resolveIssue').callsFake(function (this: FolderRepositoryManager) { assert.ok(this.repository instanceof RemoteOnlyRepository); - return undefined; + return pendingIssue; }); const openExternal = sandbox.stub(vscode.env, 'openExternal').resolves(true); + const createWebviewPanel = sandbox.spy(vscode.window, 'createWebviewPanel'); try { registration = registerGitHubIssueOrPullRequestExternalUriOpener( @@ -57,11 +60,21 @@ describe('GitHubIssueOrPullRequestExternalUriOpener', () => { assert.ok(opener); sandbox.stub(opener as any, 'isOpenPullLinksEnabled').returns(true); cancellation = new vscode.CancellationTokenSource(); - await opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + for (let attempt = 0; attempt < 20 && createWebviewPanel.notCalled; attempt++) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + + assert.strictEqual(createWebviewPanel.callCount, 1); + assert.ok(createWebviewPanel.firstCall.returnValue.webview.html.includes('webview-pr-description.js')); + assert.ok(IssueOverviewPanel.findPanel('microsoft', 'vscode', 1)); + finishResolveIssue(undefined); + await opening; assert.strictEqual(repositoriesManager.folderManagers.length, 0); assert.strictEqual(resolveIssue.callCount, 1); assert.ok(openExternal.calledOnceWith(uri, { allowContributedOpeners: 'default' })); + assert.strictEqual(IssueOverviewPanel.findPanel('microsoft', 'vscode', 1), undefined); } finally { cancellation?.dispose(); registration?.dispose(); @@ -116,107 +129,114 @@ describe('GitHubIssueOrPullRequestExternalUriOpener', () => { } }); - describe('opening pull requests', () => { - const uri = vscode.Uri.parse('https://github.com/aaa/bbb/pull/1000'); - let context: MockExtensionContext; - let opener: vscode.ExternalUriOpener; - let cancellation: vscode.CancellationTokenSource; - let resolvePullRequest: (pr: PullRequestModel | undefined) => void; - let rejectPullRequest: (error: Error) => void; - let resolvePullRequestStub: SinonStub, ReturnType>; - let openExternal: SinonStub, ReturnType>; - - beforeEach(() => { - context = new MockExtensionContext(); - const telemetry = new MockTelemetry(); - const credentialStore = new CredentialStore(telemetry, context); - const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); - const resolver = new FolderRepositoryManagerResolver(context, repositoriesManager, telemetry); - cancellation = new vscode.CancellationTokenSource(); - context.subscriptions.push(credentialStore, repositoriesManager, resolver, cancellation); - sandbox.stub(vscode.window, 'registerExternalUriOpener').callsFake((_id, value) => { - opener = value; - return new vscode.Disposable(() => undefined); - }); - context.subscriptions.push(registerGitHubIssueOrPullRequestExternalUriOpener(context, resolver, telemetry)); - sandbox.stub(opener as any, 'isOpenPullLinksEnabled').returns(true); - const pendingPullRequest = new Promise((resolve, reject) => { - resolvePullRequest = resolve; - rejectPullRequest = reject; + for (const kind of ['pullRequest', 'issue'] as const) { + describe(`opening ${kind === 'pullRequest' ? 'pull requests' : 'issues'}`, () => { + const panel = kind === 'pullRequest' ? PullRequestOverviewPanel : IssueOverviewPanel; + const uri = vscode.Uri.parse(`https://github.com/aaa/bbb/${kind === 'pullRequest' ? 'pull' : 'issues'}/1000`); + let context: MockExtensionContext; + let opener: vscode.ExternalUriOpener; + let cancellation: vscode.CancellationTokenSource; + let resolveModel: (model: undefined) => void; + let rejectModel: (error: Error) => void; + let resolveModelStub: + | SinonStub, ReturnType> + | SinonStub, ReturnType>; + let openExternal: SinonStub, ReturnType>; + + beforeEach(() => { + context = new MockExtensionContext(); + const telemetry = new MockTelemetry(); + const credentialStore = new CredentialStore(telemetry, context); + const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); + const resolver = new FolderRepositoryManagerResolver(context, repositoriesManager, telemetry); + cancellation = new vscode.CancellationTokenSource(); + context.subscriptions.push(credentialStore, repositoriesManager, resolver, cancellation); + sandbox.stub(vscode.window, 'registerExternalUriOpener').callsFake((_id, value) => { + opener = value; + return new vscode.Disposable(() => undefined); + }); + context.subscriptions.push(registerGitHubIssueOrPullRequestExternalUriOpener(context, resolver, telemetry)); + sandbox.stub(opener as any, 'isOpenPullLinksEnabled').returns(true); + const pendingModel = new Promise((resolve, reject) => { + resolveModel = resolve; + rejectModel = reject; + }); + resolveModelStub = kind === 'pullRequest' + ? sandbox.stub(FolderRepositoryManager.prototype, 'resolvePullRequest').returns(pendingModel) + : sandbox.stub(FolderRepositoryManager.prototype, 'resolveIssue').returns(pendingModel); + openExternal = sandbox.stub(vscode.env, 'openExternal').resolves(true); }); - resolvePullRequestStub = sandbox.stub(FolderRepositoryManager.prototype, 'resolvePullRequest').returns(pendingPullRequest); - openExternal = sandbox.stub(vscode.env, 'openExternal').resolves(true); - }); - afterEach(() => { - PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000)?.dispose(); - context.dispose(); - }); + afterEach(() => { + panel.findPanel('aaa', 'bbb', 1000)?.dispose(); + context.dispose(); + }); - it('creates the first tab and loads its HTML before resolving the PR', async () => { - const createWebviewPanel = sandbox.spy(vscode.window, 'createWebviewPanel'); - const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); - try { - assert.strictEqual(createWebviewPanel.callCount, 1); - assert.ok(createWebviewPanel.firstCall.returnValue.webview.html.includes('webview-pr-description.js')); - assert.ok(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000)); - sandbox.assert.calledOnce(resolvePullRequestStub); - sandbox.assert.calledWithExactly(resolvePullRequestStub, 'aaa', 'bbb', 1000, true, 'overview'); - } finally { - resolvePullRequest(undefined); - await opening; - } - sandbox.assert.calledOnce(resolvePullRequestStub); - sandbox.assert.calledOnce(openExternal); - sandbox.assert.calledWithExactly(openExternal, uri, { allowContributedOpeners: 'default' }); - sandbox.assert.notCalled(showError); - assert.strictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); - }); + it('creates the first tab and loads its HTML before resolving the model', async () => { + const createWebviewPanel = sandbox.spy(vscode.window, 'createWebviewPanel'); + const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + try { + assert.strictEqual(createWebviewPanel.callCount, 1); + assert.ok(createWebviewPanel.firstCall.returnValue.webview.html.includes('webview-pr-description.js')); + assert.ok(panel.findPanel('aaa', 'bbb', 1000)); + sandbox.assert.calledOnce(resolveModelStub); + assert.deepStrictEqual(resolveModelStub.firstCall.args, ['aaa', 'bbb', 1000, true, kind === 'pullRequest' ? 'overview' : true]); + } finally { + resolveModel(undefined); + await opening; + } + sandbox.assert.calledOnce(resolveModelStub); + sandbox.assert.calledOnce(openExternal); + sandbox.assert.calledWithExactly(openExternal, uri, { allowContributedOpeners: 'default' }); + sandbox.assert.notCalled(showError); + assert.strictEqual(panel.findPanel('aaa', 'bbb', 1000), undefined); + }); - it('does not create a tab for an already-cancelled request', async () => { - const createWebviewPanel = sandbox.spy(vscode.window, 'createWebviewPanel'); - cancellation.cancel(); - await opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + it('does not create a tab for an already-cancelled request', async () => { + const createWebviewPanel = sandbox.spy(vscode.window, 'createWebviewPanel'); + cancellation.cancel(); + await opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); - sandbox.assert.notCalled(createWebviewPanel); - sandbox.assert.notCalled(resolvePullRequestStub); - sandbox.assert.notCalled(openExternal); - }); + sandbox.assert.notCalled(createWebviewPanel); + sandbox.assert.notCalled(resolveModelStub); + sandbox.assert.notCalled(openExternal); + }); - it('closes the new tab without an error when cancelled during resolution', async () => { - const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); - assert.ok(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000)); - cancellation.cancel(); - resolvePullRequest(undefined); - await opening; + it('closes the new tab without an error when cancelled during resolution', async () => { + const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + assert.ok(panel.findPanel('aaa', 'bbb', 1000)); + cancellation.cancel(); + resolveModel(undefined); + await opening; - assert.strictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); - sandbox.assert.notCalled(showError); - sandbox.assert.notCalled(openExternal); - }); + assert.strictEqual(panel.findPanel('aaa', 'bbb', 1000), undefined); + sandbox.assert.notCalled(showError); + sandbox.assert.notCalled(openExternal); + }); - it('reports a browser fallback failure and closes the new tab', async () => { - openExternal.rejects(new Error('Browser unavailable')); - const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); - resolvePullRequest(undefined); - await opening; + it('reports a browser fallback failure and closes the new tab', async () => { + openExternal.rejects(new Error('Browser unavailable')); + const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + resolveModel(undefined); + await opening; - sandbox.assert.calledOnce(resolvePullRequestStub); - assert.strictEqual(showError.firstCall.args[0], 'Browser unavailable'); - assert.strictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); - }); + sandbox.assert.calledOnce(resolveModelStub); + assert.strictEqual(showError.firstCall.args[0], 'Browser unavailable'); + assert.strictEqual(panel.findPanel('aaa', 'bbb', 1000), undefined); + }); - it('reports resolution failures and closes the new tab', async () => { - const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); - rejectPullRequest(new Error('PR lookup failed')); - await opening; + it('reports resolution failures and closes the new tab', async () => { + const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const opening = opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + rejectModel(new Error('Item lookup failed')); + await opening; - assert.strictEqual(showError.firstCall.args[0], 'PR lookup failed'); - assert.strictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); + assert.strictEqual(showError.firstCall.args[0], 'Item lookup failed'); + assert.strictEqual(panel.findPanel('aaa', 'bbb', 1000), undefined); + }); }); - }); + } }); diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 93df1050c4..a48273d35e 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -154,6 +154,44 @@ describe('GitHubRepository', function () { assert.strictEqual(deletedAuthorPreview.head, ''); }); + it('loads a read-only issue preview without populating the issue model cache', async function () { + const preview = { + number: 1347, title: 'Preview', titleHTML: 'Preview', + body: 'Description', bodyHTML: '

Description

', url: 'https://github.com/owner/repo/issues/1347', + state: GithubItemStateEnum.Open, stateReason: null, createdAt: '2026-10-01T10:00:00Z', + author: { __typename: 'User', id: 'author', login: 'contributor', url: 'https://github.com/contributor', avatarUrl: '' }, + }; + const query = sinon.stub(repo, 'query').resolves({ + data: { repository: { issue: preview } }, + loading: false, stale: false, networkStatus: NetworkStatus.ready, + }); + + const { author, ...content } = preview; + assert.deepStrictEqual(await repo.getIssuePreview(1347), { + ...content, + author: parseAccount(author, repo), + isIssue: true, + }); + assert.strictEqual(repo.getExistingIssueModel(1347), undefined); + sinon.assert.calledOnce(query); + assert.strictEqual(query.firstCall.args[0].query, repo.schema.IssuePreview); + assert.deepStrictEqual(query.firstCall.args[0].variables, { owner: 'owner', name: 'repo', number: 1347 }); + const fields: string[] = []; + visit(repo.schema.IssuePreview, { Field(node) { fields.push(node.name.value); } }); + assert.ok(fields.includes('titleHTML') && fields.includes('bodyHTML')); + for (const field of ['comments', 'labels', 'assignees', 'projectItems', 'reactionGroups', 'timelineItems']) { + assert.ok(!fields.includes(field), `Preview must not query ${field}`); + } + assert.ok(!fields.includes('email'), 'Preview must not require additional user scopes'); + + query.resolves({ + data: { repository: { issue: { ...preview, author: null } } }, + loading: false, stale: false, networkStatus: NetworkStatus.ready, + }); + const deletedAuthorPreview = await repo.getIssuePreview(1347); + assert.deepStrictEqual(deletedAuthorPreview.author, parseAccount(null, repo)); + }); + it('rejects missing previews and invalid preview numbers', async function () { const query = sinon.stub(repo, 'query').resolves({ data: { repository: { pullRequest: null } }, @@ -167,6 +205,19 @@ describe('GitHubRepository', function () { assert.strictEqual(repo.getExistingPullRequestModel(1347), undefined); }); + it('rejects missing issue previews and invalid issue preview numbers', async function () { + const query = sinon.stub(repo, 'query').resolves({ + data: { repository: { issue: null } }, + loading: false, stale: false, networkStatus: NetworkStatus.ready, + }); + for (const number of [0, -1, NaN, Infinity, 1.5]) { + await assert.rejects(repo.getIssuePreview(number), /Invalid issue number/); + } + sinon.assert.notCalled(query); + await assert.rejects(repo.getIssuePreview(1347), /Unable to load issue preview/); + assert.strictEqual(repo.getExistingIssueModel(1347), undefined); + }); + it('loads an overview with only the PR query and reuses its cached model', async function () { const data = new GraphQLPullRequestBuilder().build(); const query = sinon.stub(repo, 'query').resolves({ data, loading: false, stale: false, networkStatus: NetworkStatus.ready }); diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index 7e06f7f63e..9ffb40bc68 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -11,7 +11,8 @@ import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockRepository } from '../mocks/mockRepository'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; -import { panelKey } from '../../github/issueOverview'; +import { IssueOverviewPanel, panelKey } from '../../github/issueOverview'; +import { IssueModel } from '../../github/issueModel'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { Protocol } from '../../common/protocol'; @@ -21,6 +22,7 @@ import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { Repository } from '../../api/api'; import { GitApiImpl } from '../../api/api1'; +import { openDescription } from '../../commands'; import { CredentialStore } from '../../github/credentials'; import { GitHubServerType } from '../../common/authentication'; import { GitHubRemote } from '../../common/remote'; @@ -34,7 +36,7 @@ import { TimelineEvent } from '../../common/timelineEvent'; import { PullRequestReviewCommon, ReviewContext } from '../../github/pullRequestReviewCommon'; import { COPILOT_REVIEWER_ACCOUNT } from '../../common/copilot'; import Logger from '../../common/logger'; -import { PullRequest, PullRequestPreview } from '../../github/views'; +import { Issue, IssuePreview, OverviewItemPreview, PullRequest, PullRequestPreview } from '../../github/views'; const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); @@ -76,6 +78,7 @@ describe('PullRequestOverview', function () { for (const panel of (PullRequestOverviewPanel as any)._panels.values()) { panel.dispose(); } + IssueOverviewPanel.clearAll(); pullRequestManager.dispose(); repositoriesManager.dispose(); @@ -91,6 +94,239 @@ describe('PullRequestOverview', function () { } }); + function previewLoadingTests(fixture: () => { + model: TItem; + preview: TPreview; + getPreview: SinonStub<[number], Promise>; + getRepository: SinonStub, ReturnType>; + getRepositoryAccess: SinonStub, ReturnType>; + getAssignableUsers: SinonStub, ReturnType>; + openPanel: (model?: TItem | Promise) => Promise; + messages: { command: string; pullrequest?: Partial }[]; + webviewPanel: vscode.WebviewPanel; + }) { + it('shows the title and description while the full model is still pending', async function () { + const { model, preview, openPanel, messages, getAssignableUsers } = fixture(); + let resolveModel!: (model: TItem) => void; + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + try { + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(messages.find(message => message.command === 'pr.preview')?.pullrequest, preview); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + sinon.assert.notCalled(getAssignableUsers); + } finally { + resolveModel(model); + await opening; + } + assert.ok(messages.some(message => message.command === 'pr.initialize')); + }); + + it('does not fetch a preview for an already available model', async function () { + const { openPanel, getPreview, getRepository } = fixture(); + await openPanel(); + sinon.assert.notCalled(getRepository); + sinon.assert.notCalled(getPreview); + }); + + for (const previewHasStarted of [false, true]) { + it(`shows a preview during slow initialization when the model resolves ${previewHasStarted ? 'after' : 'before'} the preview query starts`, async function () { + const { model, preview, getPreview, getRepositoryAccess, openPanel, messages } = fixture(); + let resolveModel: ((model: TItem) => void) | undefined; + let resolvePreview!: (preview: TPreview) => void; + let resolveAccess!: (access: Awaited>) => void; + getRepositoryAccess.returns(new Promise(resolve => resolveAccess = resolve)); + getPreview.returns(new Promise(resolve => resolvePreview = resolve)); + const pendingModel = previewHasStarted + ? new Promise(resolve => resolveModel = resolve) + : Promise.resolve(model); + const opening = openPanel(pendingModel); + try { + if (previewHasStarted) { + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.calledOnce(getPreview); + resolveModel!(model); + } + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.calledOnce(getRepositoryAccess); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + + resolvePreview(preview); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(messages.filter(message => message.command === 'pr.preview').pop()?.pullrequest, preview); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + } finally { + resolveModel?.(model); + resolvePreview(preview); + resolveAccess({ + hasWritePermission: true, + mergeMethodsAvailability: { merge: true, squash: true, rebase: true }, + viewerCanAutoMerge: false, + }); + await opening; + } + assert.ok(messages.some(message => message.command === 'pr.initialize')); + }); + } + + it('ignores a preview from an older lookup while a newer lookup is pending', async function () { + const { model, preview, getPreview, openPanel, messages } = fixture(); + let resolvePreview!: (value: TPreview) => void; + let resolveFirst!: (value: TItem) => void; + let resolveSecond!: (value: TItem) => void; + getPreview.onFirstCall().returns(new Promise(resolve => resolvePreview = resolve)); + const first = openPanel(new Promise(resolve => resolveFirst = resolve)); + await new Promise(resolve => setImmediate(resolve)); + const second = openPanel(new Promise(resolve => resolveSecond = resolve)); + try { + await new Promise(resolve => setImmediate(resolve)); + messages.length = 0; + resolvePreview(preview); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(messages.some(message => message.command === 'pr.preview'), false); + } finally { + resolveFirst(model); + resolveSecond(model); + await Promise.all([first, second]); + } + }); + + it('does not let a late preview replace the complete model', async function () { + const { model, preview, getPreview, openPanel, messages } = fixture(); + let resolvePreview!: (value: TPreview) => void; + let resolveModel!: (value: TItem) => void; + getPreview.returns(new Promise(resolve => resolvePreview = resolve)); + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + await new Promise(resolve => setImmediate(resolve)); + resolveModel(model); + await opening; + const previews = messages.filter(message => message.command === 'pr.preview'); + resolvePreview(preview); + await new Promise(resolve => setImmediate(resolve)); + + assert.deepStrictEqual(messages.filter(message => message.command === 'pr.preview'), previews); + assert.ok(messages.some(message => message.command === 'pr.initialize')); + }); + + it('ignores a preview that finishes after the panel is closed', async function () { + const { model, preview, getPreview, openPanel, messages, webviewPanel } = fixture(); + let resolvePreview!: (value: TPreview) => void; + let resolveModel!: (value: TItem) => void; + getPreview.returns(new Promise(resolve => resolvePreview = resolve)); + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + await new Promise(resolve => setImmediate(resolve)); + webviewPanel.dispose(); + resolvePreview(preview); + resolveModel(model); + await opening; + + assert.strictEqual(messages.some(message => message.command === 'pr.preview'), false); + }); + + it('logs a preview failure without preventing full initialization', async function () { + const { model, preview, getPreview, openPanel, messages } = fixture(); + let resolveModel!: (value: TItem) => void; + getPreview.rejects(new Error('Preview unavailable')); + const logError = sinon.spy(Logger, 'error'); + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + try { + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.calledWith(logError, + `Unable to load ${preview.isIssue ? 'Issue' : 'PR'} overview preview: Preview unavailable`, + preview.isIssue ? IssueOverviewPanel.ID : PullRequestOverviewPanel.ID); + } finally { + resolveModel(model); + await opening; + } + assert.ok(messages.some(message => message.command === 'pr.initialize')); + }); + + it('loads the webview before a supplied model resolves', async function () { + const { model, openPanel, messages, webviewPanel, getAssignableUsers } = fixture(); + let resolveModel!: (model: TItem) => void; + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + try { + assert.ok(webviewPanel.webview.html.includes('webview-pr-description.js')); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + sinon.assert.notCalled(getAssignableUsers); + } finally { + resolveModel(model); + await opening; + } + assert.strictEqual(messages.find(message => message.command === 'pr.initialize')?.pullrequest?.title, model.title); + }); + + it('does not initialize a closed panel when its model resolves', async function () { + const { model, openPanel, messages, webviewPanel, getAssignableUsers } = fixture(); + let resolveModel!: (model: TItem) => void; + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + webviewPanel.dispose(); + resolveModel(model); + await opening; + + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + sinon.assert.notCalled(getAssignableUsers); + }); + + it('does not overwrite a newer model when an older lookup finishes', async function () { + const { model, openPanel, messages, getAssignableUsers } = fixture(); + let resolveModel!: (model: TItem) => void; + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + await openPanel(); + messages.length = 0; + resolveModel(model); + await opening; + + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + sinon.assert.calledOnce(getAssignableUsers); + }); + + it('skips an older repository lookup while a newer model lookup is still pending', async function () { + const { model, getRepository, getPreview, openPanel } = fixture(); + let resolveRepository!: (repository: MockGitHubRepository) => void; + let resolveFirst!: (model: TItem) => void; + let resolveSecond!: (model: TItem) => void; + getRepository.onFirstCall().returns(new Promise(resolve => resolveRepository = resolve)); + const first = openPanel(new Promise(resolve => resolveFirst = resolve)); + const second = openPanel(new Promise(resolve => resolveSecond = resolve)); + try { + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.calledOnce(getPreview); + resolveRepository(repo); + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.calledOnce(getPreview); + } finally { + resolveRepository(repo); + resolveFirst(model); + resolveSecond(model); + await Promise.all([first, second]); + } + }); + + for (const state of ['initialized', 'disposed']) { + it(`skips the preview query when repository lookup finishes after the load is ${state}`, async function () { + const { model, getRepository, getPreview, openPanel, webviewPanel } = fixture(); + let resolveRepository!: (repository: MockGitHubRepository) => void; + let resolveModel!: (model: TItem) => void; + getRepository.onFirstCall().returns(new Promise(resolve => resolveRepository = resolve)); + const opening = openPanel(new Promise(resolve => resolveModel = resolve)); + try { + sinon.assert.calledOnce(getRepository); + if (state === 'disposed') { + webviewPanel.dispose(); + } + resolveModel(model); + await opening; + } finally { + resolveRepository(repo); + resolveModel(model); + await opening; + } + await new Promise(resolve => setImmediate(resolve)); + sinon.assert.notCalled(getPreview); + }); + } + } + describe('createOrShow', function () { it('does not load stack membership when stacks are disabled', async function () { setStacksEnabled(false); @@ -368,6 +604,8 @@ describe('PullRequestOverview', function () { let getAssignableUsers: SinonStub, ReturnType>; let getReviewRequests: SinonStub<[], ReturnType>; let getPreview: SinonStub<[number], Promise>; + let getRepository: SinonStub, ReturnType>; + let getRepositoryAccess: SinonStub, ReturnType>; let getMergeQueueMethod: SinonStub; const preview: PullRequestPreview = { number: 1000, title: 'Preview title', titleHTML: 'Preview title', @@ -379,8 +617,13 @@ describe('PullRequestOverview', function () { beforeEach(function () { const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); - sinon.stub(pullRequestManager, 'createGitHubRepositoryFromOwnerName').resolves(repo); + getRepository = sinon.stub(pullRequestManager, 'createGitHubRepositoryFromOwnerName').resolves(repo); getPreview = sinon.stub(repo, 'getPullRequestPreview').resolves(preview); + getRepositoryAccess = sinon.stub(pullRequestManager, 'getPullRequestRepositoryAccessAndMergeMethods').resolves({ + hasWritePermission: true, + mergeMethodsAvailability: { merge: true, squash: true, rebase: true }, + viewerCanAutoMerge: false, + }); sinon.stub(pullRequestManager, 'getCurrentUser').resolves(prModel.author); sinon.stub(prModel, 'canEdit').resolves(true); getReviewRequests = sinon.stub(prModel, 'getReviewRequests').resolves([]); @@ -426,25 +669,7 @@ describe('PullRequestOverview', function () { await new Promise(resolve => setImmediate(resolve)); }); - it('shows the title and description while the full PR is still pending', async function () { - let resolveModel: (model: PullRequestModel) => void; - const opening = openPanel(new Promise(resolve => resolveModel = resolve)); - try { - await new Promise(resolve => setImmediate(resolve)); - assert.deepStrictEqual(messages.find(message => message.command === 'pr.preview')?.pullrequest, preview); - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - sinon.assert.notCalled(getAssignableUsers); - } finally { - resolveModel!(prModel); - await opening; - } - assert.ok(messages.some(message => message.command === 'pr.initialize')); - }); - - it('does not fetch a preview for an already available PR model', async function () { - await openPanel(); - sinon.assert.notCalled(getPreview); - }); + previewLoadingTests(() => ({ model: prModel, preview, getPreview, getRepository, getRepositoryAccess, getAssignableUsers, openPanel, messages, webviewPanel })); it('keeps the stack queue method when deferred PR data arrives after the stack', async function () { sinon.stub(vscode.env, 'asExternalUri').callsFake(async uri => uri); @@ -508,42 +733,6 @@ describe('PullRequestOverview', function () { assert.strictEqual((panel as any)._stackLoaded, true); }); - for (const previewHasStarted of [false, true]) { - it(`shows a preview during slow initialization when the model resolves ${previewHasStarted ? 'after' : 'before'} the preview query starts`, async function () { - let resolveModel: ((model: PullRequestModel) => void) | undefined; - let resolvePreview: (value: PullRequestPreview) => void; - let resolveDefaultBranch: (branch: string) => void; - const getDefaultBranch = sinon.stub(pullRequestManager, 'getPullRequestRepositoryDefaultBranch') - .returns(new Promise(resolve => resolveDefaultBranch = resolve)); - getPreview.returns(new Promise(resolve => resolvePreview = resolve)); - const model = previewHasStarted - ? new Promise(resolve => resolveModel = resolve) - : Promise.resolve(prModel); - const opening = openPanel(model); - try { - if (previewHasStarted) { - await new Promise(resolve => setImmediate(resolve)); - sinon.assert.calledOnce(getPreview); - resolveModel!(prModel); - } - await new Promise(resolve => setImmediate(resolve)); - sinon.assert.calledOnce(getDefaultBranch); - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - - resolvePreview!(preview); - await new Promise(resolve => setImmediate(resolve)); - assert.deepStrictEqual(messages.find(message => message.command === 'pr.preview')?.pullrequest, preview); - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - } finally { - resolveModel?.(prModel); - resolvePreview!(preview); - resolveDefaultBranch!('main'); - await opening; - } - assert.ok(messages.some(message => message.command === 'pr.initialize')); - }); - } - it('skips polling a pending model and resumes once the PR is available', async function () { const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const getLastUpdateTime = sinon.stub(prModel, 'getLastUpdateTime').resolves(new Date(0)); @@ -618,110 +807,6 @@ describe('PullRequestOverview', function () { } }); - it('ignores a preview from an older lookup while a newer lookup is pending', async function () { - let resolvePreview: (value: PullRequestPreview) => void; - let resolveFirst: (value: PullRequestModel) => void; - let resolveSecond: (value: PullRequestModel) => void; - getPreview.onFirstCall().returns(new Promise(resolve => resolvePreview = resolve)); - const first = openPanel(new Promise(resolve => resolveFirst = resolve)); - await new Promise(resolve => setImmediate(resolve)); - const second = openPanel(new Promise(resolve => resolveSecond = resolve)); - try { - await new Promise(resolve => setImmediate(resolve)); - messages.length = 0; - resolvePreview!({ ...preview, title: 'Outdated preview' }); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(messages.some(message => message.command === 'pr.preview'), false); - } finally { - resolveFirst!(prModel); - resolveSecond!(prModel); - await Promise.all([first, second]); - } - }); - - it('does not let a late preview replace the complete PR', async function () { - let resolvePreview: (value: PullRequestPreview) => void; - let resolveModel: (value: PullRequestModel) => void; - getPreview.returns(new Promise(resolve => resolvePreview = resolve)); - const opening = openPanel(new Promise(resolve => resolveModel = resolve)); - await new Promise(resolve => setImmediate(resolve)); - resolveModel!(prModel); - await opening; - resolvePreview!(preview); - await new Promise(resolve => setImmediate(resolve)); - - assert.strictEqual(messages.some(message => message.command === 'pr.preview'), false); - assert.ok(messages.some(message => message.command === 'pr.initialize')); - }); - - it('ignores a preview that finishes after the panel is closed', async function () { - let resolvePreview: (value: PullRequestPreview) => void; - let resolveModel: (value: PullRequestModel) => void; - getPreview.returns(new Promise(resolve => resolvePreview = resolve)); - const opening = openPanel(new Promise(resolve => resolveModel = resolve)); - await new Promise(resolve => setImmediate(resolve)); - webviewPanel.dispose(); - resolvePreview!(preview); - resolveModel!(prModel); - await opening; - - assert.strictEqual(messages.some(message => message.command === 'pr.preview'), false); - }); - - it('logs a preview failure without preventing full PR initialization', async function () { - let resolveModel: (value: PullRequestModel) => void; - getPreview.rejects(new Error('Preview unavailable')); - const logError = sinon.spy(Logger, 'error'); - const opening = openPanel(new Promise(resolve => resolveModel = resolve)); - try { - await new Promise(resolve => setImmediate(resolve)); - assert.ok(logError.getCalls().some(call => typeof call.args[0] === 'string' && call.args[0].includes('Preview unavailable'))); - } finally { - resolveModel!(prModel); - await opening; - } - assert.ok(messages.some(message => message.command === 'pr.initialize')); - }); - - it('loads the webview before a supplied PR model resolves', async function () { - let resolveModel: (model: PullRequestModel) => void; - const pendingModel = new Promise(resolve => resolveModel = resolve); - const opening = openPanel(pendingModel); - try { - assert.ok(webviewPanel.webview.html.includes('webview-pr-description.js')); - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - } finally { - resolveModel!(prModel); - await opening; - } - assert.strictEqual(messages.find(message => message.command === 'pr.initialize')?.pullrequest?.title, prModel.title); - }); - - it('does not initialize a closed panel when its PR model resolves', async function () { - let resolveModel: (model: PullRequestModel) => void; - const pendingModel = new Promise(resolve => resolveModel = resolve); - const opening = openPanel(pendingModel); - webviewPanel.dispose(); - resolveModel!(prModel); - await opening; - - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - sinon.assert.notCalled(getAssignableUsers); - }); - - it('does not overwrite a newer model when an older lookup finishes', async function () { - let resolveModel: (model: PullRequestModel) => void; - const pendingModel = new Promise(resolve => resolveModel = resolve); - const opening = openPanel(pendingModel); - await openPanel(); - messages.length = 0; - resolveModel!(prModel); - await opening; - - assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); - sinon.assert.calledOnce(getAssignableUsers); - }); - it('initializes the PR and loads checks before cold assignable users finish', async function () { const opening = openPanel(); await new Promise(resolve => setImmediate(resolve)); @@ -798,6 +883,115 @@ describe('PullRequestOverview', function () { }); }); + describe('issue preview loading', function () { + let issueModel: IssueModel; + let webviewPanel: vscode.WebviewPanel; + let messages: { command: string; pullrequest?: Partial }[]; + let onDidReceiveMessage: vscode.EventEmitter<{ command: string }>; + let getPreview: SinonStub<[number], Promise>; + let getRepository: SinonStub, ReturnType>; + let getRepositoryAccess: SinonStub, ReturnType>; + let getAssignableUsers: SinonStub, ReturnType>; + const preview: IssuePreview = { + number: 1000, title: 'Preview title', titleHTML: 'Preview title', + body: 'Preview description', bodyHTML: '

Preview description

', url: 'https://github.com/aaa/bbb/issues/1000', + author: COPILOT_REVIEWER_ACCOUNT, createdAt: '2026-10-01T10:00:00Z', + state: GithubItemStateEnum.Open, isIssue: true, + }; + + beforeEach(function () { + context.extensionUri = EXTENSION_URI; + const item = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + issueModel = new IssueModel(telemetry, repo, remote, { ...item, url: 'https://github.com/aaa/bbb/issues/1000' }); + context.subscriptions.push(issueModel); + getRepository = sinon.stub(pullRequestManager, 'createGitHubRepositoryFromOwnerName').resolves(repo); + getPreview = sinon.stub(repo, 'getIssuePreview').resolves(preview); + sinon.stub(pullRequestManager, 'resolveIssue').resolves(issueModel); + sinon.stub(issueModel, 'getIssueTimelineEvents').resolves([]); + sinon.stub(issueModel, 'canEdit').resolves(true); + sinon.stub(pullRequestManager, 'getCurrentUser').resolves(issueModel.author); + getAssignableUsers = sinon.stub(pullRequestManager, 'getAssignableUsers').resolves({}); + getRepositoryAccess = sinon.stub(pullRequestManager, 'getPullRequestRepositoryAccessAndMergeMethods').resolves({ + hasWritePermission: true, + mergeMethodsAvailability: { merge: true, squash: true, rebase: true }, + viewerCanAutoMerge: false, + }); + + messages = []; + webviewPanel = vscode.window.createWebviewPanel(IssueOverviewPanel.viewType, '#1000', vscode.ViewColumn.One, {}); + onDidReceiveMessage = new vscode.EventEmitter(); + context.subscriptions.push(webviewPanel, onDidReceiveMessage); + sinon.stub(webviewPanel.webview, 'onDidReceiveMessage').callsFake(onDidReceiveMessage.event); + sinon.stub(webviewPanel.webview, 'postMessage').callsFake(async message => { + messages.push(message.res); + return true; + }); + }); + + async function openPanel(model: IssueModel | Promise = issueModel): Promise { + const identity = { owner: remote.owner, repo: remote.repositoryName, number: issueModel.number }; + const opening = IssueOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity, model, false, true, webviewPanel); + onDidReceiveMessage.fire({ command: 'ready' }); + await opening; + await new Promise(resolve => setImmediate(resolve)); + } + + for (const bodyHTML of ['

Rendered issue description

', undefined]) { + it(`shows a model-backed preview when opening an issue through the description command ${bodyHTML === undefined ? 'without' : 'with'} body HTML`, async function () { + issueModel.bodyHTML = bodyHTML; + let resolveUsers!: (users: { [key: string]: IAccount[] }) => void; + getAssignableUsers.returns(new Promise(resolve => resolveUsers = resolve)); + const createPanel = sinon.stub(vscode.window, 'createWebviewPanel').returns(webviewPanel); + const opening = openDescription(telemetry, issueModel, undefined, pullRequestManager, false); + onDidReceiveMessage.fire({ command: 'ready' }); + try { + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(messages.find(message => message.command === 'pr.preview')?.pullrequest, { + number: issueModel.number, + title: issueModel.title, + titleHTML: issueModel.titleHTML, + url: issueModel.html_url, + body: issueModel.body, + bodyHTML: issueModel.bodyHTML, + author: issueModel.author, + createdAt: issueModel.createdAt, + state: issueModel.state, + stateReason: issueModel.stateReason, + isIssue: true, + }); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + sinon.assert.calledOnce(createPanel); + sinon.assert.notCalled(getRepository); + sinon.assert.notCalled(getPreview); + } finally { + resolveUsers({}); + await opening; + } + assert.ok(messages.some(message => message.command === 'pr.initialize')); + }); + } + + it('keeps the complete issue visible while refreshing an existing panel', async function () { + await openPanel(); + messages.length = 0; + let resolveUsers!: (users: { [key: string]: IAccount[] }) => void; + getAssignableUsers.returns(new Promise(resolve => resolveUsers = resolve)); + const refreshing = openPanel(); + try { + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(messages.some(message => message.command === 'pr.preview' || message.command === 'pr.clear'), false); + assert.strictEqual(messages.some(message => message.command === 'pr.initialize'), false); + } finally { + resolveUsers({}); + await refreshing; + } + assert.ok(messages.some(message => message.command === 'pr.initialize')); + sinon.assert.notCalled(getPreview); + }); + + previewLoadingTests(() => ({ model: issueModel, preview, getPreview, getRepository, getRepositoryAccess, getAssignableUsers, openPanel, messages, webviewPanel })); + }); + describe('mergePullRequest', function () { it('prompts to delete the local branch when GitHub deletes branches after merge', async function () { repo.buildMetadata(repository => repository.delete_branch_on_merge!(true)); diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index e352990012..d512f6ccd6 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -11,7 +11,7 @@ import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArg import { IComment } from '../../src/common/comment'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; -import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, PullRequestPreview, ReadyForReviewReply, StackMergeResult, SubmitReviewArgs, SubmitReviewReply, UnstackAllResult, UpdateStackResult, UploadFilesReply } from '../../src/github/views'; +import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, OverviewItemPreview, ProjectItemsReply, PullRequest, ReadyForReviewReply, StackMergeResult, SubmitReviewArgs, SubmitReviewReply, UnstackAllResult, UpdateStackResult, UploadFilesReply } from '../../src/github/views'; /** * Encode a {@linkcode Uint8Array} as a base64 string. Uses fixed-size chunks to @@ -32,8 +32,8 @@ function bytesToBase64(bytes: Uint8Array): string { const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; export class PRContext { - public preview: PullRequestPreview | undefined; - public onPreviewChange: ((preview: PullRequestPreview | undefined) => void) | null = null; + public preview: OverviewItemPreview | undefined; + public onPreviewChange: ((preview: OverviewItemPreview | undefined) => void) | null = null; constructor( public pr: PullRequest | undefined = getState(), diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index 30242de31d..6390c4b571 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -12,7 +12,7 @@ import { AuthorLink, Avatar } from './user'; import { IComment } from '../../src/common/comment'; import { CommentEvent, EventType, ReviewEvent } from '../../src/common/timelineEvent'; import { GithubItemStateEnum } from '../../src/github/interface'; -import { PullRequest, PullRequestPreview, ReviewCommentContext, ReviewType } from '../../src/github/views'; +import { OverviewItemPreview, PullRequest, ReviewCommentContext, ReviewType } from '../../src/github/views'; import { ariaAnnouncementForReview } from '../common/aria'; import { COMMENT_TEXTAREA_ID } from '../common/constants'; import PullRequestContext from '../common/context'; @@ -182,14 +182,16 @@ export function CommentView(commentProps: Props) { ); } -export const CommentPreview = (preview: PullRequestPreview) => ( +export const CommentPreview = (preview: OverviewItemPreview | PullRequest) => ( - + {preview.bodyHTML === undefined && preview.body + ?
{preview.body}
+ : }
); type CommentBoxProps = { - for: IComment | ReviewEvent | PullRequest | CommentEvent | PullRequestPreview; + for: IComment | ReviewEvent | PullRequest | CommentEvent | OverviewItemPreview; header?: React.ReactChild; onFocus?: React.FocusEventHandler; onMouseEnter?: React.MouseEventHandler; diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index 9c6cb89aee..18b6ac00e2 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -11,7 +11,7 @@ import { AuthorLink, Avatar } from './user'; import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../../src/common/copilot'; import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent'; import { GithubItemStateEnum, PullRequestStack, StateReason } from '../../src/github/interface'; -import { BaseContext, CodingAgentContext, OverviewContext, PullRequest, PullRequestPreview } from '../../src/github/views'; +import { BaseContext, CodingAgentContext, OverviewContext, OverviewItemPreview, PullRequest } from '../../src/github/views'; import { EDIT_TITLE_BUTTON_ID } from '../common/constants'; import PullRequestContext from '../common/context'; import { useStateProp } from '../common/hooks'; @@ -75,13 +75,24 @@ export function Header({ ); } -export function HeaderPreview(preview: PullRequestPreview) { +export function HeaderPreview(preview: OverviewItemPreview | PullRequest) { const { openOnGitHub } = useContext(PullRequestContext); + const pullRequest = preview.isIssue === true ? undefined : preview; return <>
- +