diff --git a/common/views.ts b/common/views.ts index a776749604..c83b28a62e 100644 --- a/common/views.ts +++ b/common/views.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { IAccount, ILabel, IMilestone, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; export interface RemoteInfo { @@ -158,4 +159,4 @@ export interface TitleAndDescriptionResult { description: string | undefined; } -// #endregion \ No newline at end of file +// #endregion diff --git a/src/@types/git-credential-node.d.ts b/src/@types/git-credential-node.d.ts index cee7de2911..ef6282cfa5 100644 --- a/src/@types/git-credential-node.d.ts +++ b/src/@types/git-credential-node.d.ts @@ -1,14 +1,15 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'git-credential-node' { - - interface Credentials { - username: string; - password: string; - } - - function fill(url: string): Promise; -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'git-credential-node' { + + interface Credentials { + username: string; + password: string; + } + + function fill(url: string): Promise; +} diff --git a/src/@types/git.d.ts b/src/@types/git.d.ts index b3d6fbceaf..500b7403e2 100644 --- a/src/@types/git.d.ts +++ b/src/@types/git.d.ts @@ -1,362 +1,363 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; -export { ProviderResult } from 'vscode'; - -export interface Git { - readonly path: string; -} - -export interface InputBox { - value: string; -} - -export const enum ForcePushMode { - Force, - ForceWithLease -} - -export const enum RefType { - Head, - RemoteHead, - Tag -} - -export interface Ref { - readonly type: RefType; - readonly name?: string; - readonly commit?: string; - readonly remote?: string; -} - -export interface UpstreamRef { - readonly remote: string; - readonly name: string; -} - -export interface Branch extends Ref { - readonly upstream?: UpstreamRef; - readonly ahead?: number; - readonly behind?: number; -} - -export interface Commit { - readonly hash: string; - readonly message: string; - readonly parents: string[]; - readonly authorDate?: Date; - readonly authorName?: string; - readonly authorEmail?: string; - readonly commitDate?: Date; -} - -export interface Submodule { - readonly name: string; - readonly path: string; - readonly url: string; -} - -export interface Remote { - readonly name: string; - readonly fetchUrl?: string; - readonly pushUrl?: string; - readonly isReadOnly: boolean; -} - -export const enum Status { - INDEX_MODIFIED, - INDEX_ADDED, - INDEX_DELETED, - INDEX_RENAMED, - INDEX_COPIED, - - MODIFIED, - DELETED, - UNTRACKED, - IGNORED, - INTENT_TO_ADD, - - ADDED_BY_US, - ADDED_BY_THEM, - DELETED_BY_US, - DELETED_BY_THEM, - BOTH_ADDED, - BOTH_DELETED, - BOTH_MODIFIED -} - -export interface Change { - - /** - * Returns either `originalUri` or `renameUri`, depending - * on whether this change is a rename change. When - * in doubt always use `uri` over the other two alternatives. - */ - readonly uri: Uri; - readonly originalUri: Uri; - readonly renameUri: Uri | undefined; - readonly status: Status; -} - -export interface RepositoryState { - readonly HEAD: Branch | undefined; - readonly remotes: Remote[]; - readonly submodules: Submodule[]; - readonly rebaseCommit: Commit | undefined; - - readonly mergeChanges: Change[]; - readonly indexChanges: Change[]; - readonly workingTreeChanges: Change[]; - - readonly onDidChange: Event; -} - -export interface RepositoryUIState { - readonly selected: boolean; - readonly onDidChange: Event; -} - -/** - * Log options. - */ -export interface LogOptions { - /** Max number of log entries to retrieve. If not specified, the default is 32. */ - readonly maxEntries?: number; - readonly path?: string; -} - -export interface CommitOptions { - all?: boolean | 'tracked'; - amend?: boolean; - signoff?: boolean; - signCommit?: boolean; - empty?: boolean; - noVerify?: boolean; - requireUserConfig?: boolean; - useEditor?: boolean; - verbose?: boolean; - /** - * string - execute the specified command after the commit operation - * undefined - execute the command specified in git.postCommitCommand - * after the commit operation - * null - do not execute any command after the commit operation - */ - postCommitCommand?: string | null; -} - -export interface FetchOptions { - remote?: string; - ref?: string; - all?: boolean; - prune?: boolean; - depth?: number; -} - -export interface RefQuery { - readonly contains?: string; - readonly count?: number; - readonly pattern?: string; - readonly sort?: 'alphabetically' | 'committerdate'; -} - -export interface BranchQuery extends RefQuery { - readonly remote?: boolean; -} - -export interface Repository { - - readonly rootUri: Uri; - readonly inputBox: InputBox; - readonly state: RepositoryState; - readonly ui: RepositoryUIState; - - getConfigs(): Promise<{ key: string; value: string; }[]>; - getConfig(key: string): Promise; - setConfig(key: string, value: string): Promise; - getGlobalConfig(key: string): Promise; - - getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; - detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; - buffer(ref: string, path: string): Promise; - show(ref: string, path: string): Promise; - getCommit(ref: string): Promise; - - add(paths: string[]): Promise; - revert(paths: string[]): Promise; - clean(paths: string[]): Promise; - - apply(patch: string, reverse?: boolean): Promise; - diff(cached?: boolean): Promise; - diffWithHEAD(): Promise; - diffWithHEAD(path: string): Promise; - diffWith(ref: string): Promise; - diffWith(ref: string, path: string): Promise; - diffIndexWithHEAD(): Promise; - diffIndexWithHEAD(path: string): Promise; - diffIndexWith(ref: string): Promise; - diffIndexWith(ref: string, path: string): Promise; - diffBlobs(object1: string, object2: string): Promise; - diffBetween(ref1: string, ref2: string): Promise; - diffBetween(ref1: string, ref2: string, path: string): Promise; - - hashObject(data: string): Promise; - - createBranch(name: string, checkout: boolean, ref?: string): Promise; - deleteBranch(name: string, force?: boolean): Promise; - getBranch(name: string): Promise; - getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; - setBranchUpstream(name: string, upstream: string): Promise; - - getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; - - getMergeBase(ref1: string, ref2: string): Promise; - - tag(name: string, upstream: string): Promise; - deleteTag(name: string): Promise; - - status(): Promise; - checkout(treeish: string): Promise; - - addRemote(name: string, url: string): Promise; - removeRemote(name: string): Promise; - renameRemote(name: string, newName: string): Promise; - - fetch(options?: FetchOptions): Promise; - fetch(remote?: string, ref?: string, depth?: number): Promise; - pull(unshallow?: boolean): Promise; - push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; - - blame(path: string): Promise; - log(options?: LogOptions): Promise; - - commit(message: string, opts?: CommitOptions): Promise; -} - -export interface RemoteSource { - readonly name: string; - readonly description?: string; - readonly url: string | string[]; -} - -export interface RemoteSourceProvider { - readonly name: string; - readonly icon?: string; // codicon name - readonly supportsQuery?: boolean; - getRemoteSources(query?: string): ProviderResult; - getBranches?(url: string): ProviderResult; - publishRepository?(repository: Repository): Promise; -} - -export interface RemoteSourcePublisher { - readonly name: string; - readonly icon?: string; // codicon name - publishRepository(repository: Repository): Promise; -} - -export interface Credentials { - readonly username: string; - readonly password: string; -} - -export interface CredentialsProvider { - getCredentials(host: Uri): ProviderResult; -} - -export interface PostCommitCommandsProvider { - getCommands(repository: Repository): Command[]; -} - -export interface PushErrorHandler { - handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; -} - -export type APIState = 'uninitialized' | 'initialized'; - -export interface PublishEvent { - repository: Repository; - branch?: string; -} - -export interface GitAPI { - readonly state: APIState; - readonly onDidChangeState: Event; - readonly onDidPublish: Event; - readonly git: Git; - readonly repositories: Repository[]; - readonly onDidOpenRepository: Event; - readonly onDidCloseRepository: Event; - - toGitUri(uri: Uri, ref: string): Uri; - getRepository(uri: Uri): Repository | null; - init(root: Uri): Promise; - openRepository(root: Uri): Promise - - registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; - registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; - registerCredentialsProvider(provider: CredentialsProvider): Disposable; - registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; - registerPushErrorHandler(handler: PushErrorHandler): Disposable; -} - -export interface GitExtension { - - readonly enabled: boolean; - readonly onDidChangeEnablement: Event; - - /** - * Returns a specific API version. - * - * Throws error if git extension is disabled. You can listen to the - * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event - * to know when the extension becomes enabled/disabled. - * - * @param version Version number. - * @returns API instance - */ - getAPI(version: 1): GitAPI; -} - -export const enum GitErrorCodes { - BadConfigFile = 'BadConfigFile', - AuthenticationFailed = 'AuthenticationFailed', - NoUserNameConfigured = 'NoUserNameConfigured', - NoUserEmailConfigured = 'NoUserEmailConfigured', - NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', - NotAGitRepository = 'NotAGitRepository', - NotAtRepositoryRoot = 'NotAtRepositoryRoot', - Conflict = 'Conflict', - StashConflict = 'StashConflict', - UnmergedChanges = 'UnmergedChanges', - PushRejected = 'PushRejected', - RemoteConnectionError = 'RemoteConnectionError', - DirtyWorkTree = 'DirtyWorkTree', - CantOpenResource = 'CantOpenResource', - GitNotFound = 'GitNotFound', - CantCreatePipe = 'CantCreatePipe', - PermissionDenied = 'PermissionDenied', - CantAccessRemote = 'CantAccessRemote', - RepositoryNotFound = 'RepositoryNotFound', - RepositoryIsLocked = 'RepositoryIsLocked', - BranchNotFullyMerged = 'BranchNotFullyMerged', - NoRemoteReference = 'NoRemoteReference', - InvalidBranchName = 'InvalidBranchName', - BranchAlreadyExists = 'BranchAlreadyExists', - NoLocalChanges = 'NoLocalChanges', - NoStashFound = 'NoStashFound', - LocalChangesOverwritten = 'LocalChangesOverwritten', - NoUpstreamBranch = 'NoUpstreamBranch', - IsInSubmodule = 'IsInSubmodule', - WrongCase = 'WrongCase', - CantLockRef = 'CantLockRef', - CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', - PatchDoesNotApply = 'PatchDoesNotApply', - NoPathFound = 'NoPathFound', - UnknownPath = 'UnknownPath', - EmptyCommitMessage = 'EmptyCommitMessage', - BranchFastForwardRejected = 'BranchFastForwardRejected', - BranchNotYetBorn = 'BranchNotYetBorn', - TagConflict = 'TagConflict' -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; +export { ProviderResult } from 'vscode'; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} + +export const enum ForcePushMode { + Force, + ForceWithLease +} + +export const enum RefType { + Head, + RemoteHead, + Tag +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED +} + +export interface Change { + + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +/** + * Log options. + */ +export interface LogOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; + readonly path?: string; +} + +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, upstream: string): Promise; + deleteTag(name: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; +} + +export interface RemoteSource { + readonly name: string; + readonly description?: string; + readonly url: string | string[]; +} + +export interface RemoteSourceProvider { + readonly name: string; + readonly icon?: string; // codicon name + readonly supportsQuery?: boolean; + getRemoteSources(query?: string): ProviderResult; + getBranches?(url: string): ProviderResult; + publishRepository?(repository: Repository): Promise; +} + +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + +export interface Credentials { + readonly username: string; + readonly password: string; +} + +export interface CredentialsProvider { + getCredentials(host: Uri): ProviderResult; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +export interface PushErrorHandler { + handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; +} + +export type APIState = 'uninitialized' | 'initialized'; + +export interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface GitAPI { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + init(root: Uri): Promise; + openRepository(root: Uri): Promise + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): Disposable; +} + +export interface GitExtension { + + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * Returns a specific API version. + * + * Throws error if git extension is disabled. You can listen to the + * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event + * to know when the extension becomes enabled/disabled. + * + * @param version Version number. + * @returns API instance + */ + getAPI(version: 1): GitAPI; +} + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + PermissionDenied = 'PermissionDenied', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', + NoPathFound = 'NoPathFound', + UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict' +} diff --git a/src/@types/graphql.d.ts b/src/@types/graphql.d.ts index 939947cf2b..f23678cf53 100644 --- a/src/@types/graphql.d.ts +++ b/src/@types/graphql.d.ts @@ -1,10 +1,11 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module '*.gql' { - import { DocumentNode } from 'graphql'; - const value: DocumentNode; - export default value; -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module '*.gql' { + import { DocumentNode } from 'graphql'; + const value: DocumentNode; + export default value; +} diff --git a/src/@types/lib.textEncoder.d.ts b/src/@types/lib.textEncoder.d.ts index 02e1b4890a..f33b94ca8c 100644 --- a/src/@types/lib.textEncoder.d.ts +++ b/src/@types/lib.textEncoder.d.ts @@ -1,11 +1,12 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Define TextEncoder + TextDecoder globals for both browser and node runtimes -// -// Proper fix: https://github.com/microsoft/TypeScript/issues/31535 - -declare let TextDecoder: typeof import('util').TextDecoder; -declare let TextEncoder: typeof import('util').TextEncoder; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// Define TextEncoder + TextDecoder globals for both browser and node runtimes +// +// Proper fix: https://github.com/microsoft/TypeScript/issues/31535 + +declare let TextDecoder: typeof import('util').TextDecoder; +declare let TextEncoder: typeof import('util').TextEncoder; diff --git a/src/@types/ref.d.ts b/src/@types/ref.d.ts index 3400855f10..8f804d9764 100644 --- a/src/@types/ref.d.ts +++ b/src/@types/ref.d.ts @@ -1,11 +1,12 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/// -/// -/// - -declare module 'tunnel'; -declare module 'ssh-config'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +/// +/// +/// + +declare module 'tunnel'; +declare module 'ssh-config'; diff --git a/src/@types/svg-inline-loader.d.ts b/src/@types/svg-inline-loader.d.ts index 77318dbd7e..84cc139302 100644 --- a/src/@types/svg-inline-loader.d.ts +++ b/src/@types/svg-inline-loader.d.ts @@ -1,3 +1,4 @@ -declare module 'svg-inline-loader' { - export default function (content: string): string; -} \ No newline at end of file +declare module 'svg-inline-loader' { + export default function (content: string): string; +} + diff --git a/src/@types/vscode-test-web.d.ts b/src/@types/vscode-test-web.d.ts index 185bba2287..8c66be76dd 100644 --- a/src/@types/vscode-test-web.d.ts +++ b/src/@types/vscode-test-web.d.ts @@ -3,6 +3,7 @@ export declare type BrowserType = 'chromium' | 'firefox' | 'webkit'; export declare type VSCodeVersion = 'insiders' | 'stable' | 'sources'; export interface Options { /** + * Browser to run the test against: 'chromium' | 'firefox' | 'webkit' */ browserType: BrowserType; diff --git a/src/@types/vscode.proposed.codiconDecoration.d.ts b/src/@types/vscode.proposed.codiconDecoration.d.ts index 2a0fc4578b..e2503e2149 100644 --- a/src/@types/vscode.proposed.codiconDecoration.d.ts +++ b/src/@types/vscode.proposed.codiconDecoration.d.ts @@ -1,48 +1,49 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/135591 @alexr00 - - // export interface FileDecorationProvider { - // provideFileDecoration(uri: Uri, token: CancellationToken): ProviderResult; - // } - - /** - * A file decoration represents metadata that can be rendered with a file. - */ - export class FileDecoration2 { - /** - * A very short string that represents this decoration. - */ - badge?: string | ThemeIcon; - - /** - * A human-readable tooltip for this decoration. - */ - tooltip?: string; - - /** - * The color of this decoration. - */ - color?: ThemeColor; - - /** - * A flag expressing that this decoration should be - * propagated to its parents. - */ - propagate?: boolean; - - /** - * Creates a new decoration. - * - * @param badge A letter that represents the decoration. - * @param tooltip The tooltip of the decoration. - * @param color The color of the decoration. - */ - constructor(badge?: string | ThemeIcon, tooltip?: string, color?: ThemeColor); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/135591 @alexr00 + + // export interface FileDecorationProvider { + // provideFileDecoration(uri: Uri, token: CancellationToken): ProviderResult; + // } + + /** + * A file decoration represents metadata that can be rendered with a file. + */ + export class FileDecoration2 { + /** + * A very short string that represents this decoration. + */ + badge?: string | ThemeIcon; + + /** + * A human-readable tooltip for this decoration. + */ + tooltip?: string; + + /** + * The color of this decoration. + */ + color?: ThemeColor; + + /** + * A flag expressing that this decoration should be + * propagated to its parents. + */ + propagate?: boolean; + + /** + * Creates a new decoration. + * + * @param badge A letter that represents the decoration. + * @param tooltip The tooltip of the decoration. + * @param color The color of the decoration. + */ + constructor(badge?: string | ThemeIcon, tooltip?: string, color?: ThemeColor); + } +} diff --git a/src/@types/vscode.proposed.commentReactor.d.ts b/src/@types/vscode.proposed.commentReactor.d.ts index 7356d025dd..ded03e13a2 100644 --- a/src/@types/vscode.proposed.commentReactor.d.ts +++ b/src/@types/vscode.proposed.commentReactor.d.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + declare module 'vscode' { export interface CommentReaction { readonly reactors?: readonly string[]; diff --git a/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts index 9b24bcc32e..c73705149f 100644 --- a/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts +++ b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts @@ -1,6 +1,7 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// empty placeholder declaration for the `comments/comment/editorActions` menu +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// empty placeholder declaration for the `comments/comment/editorActions` menu diff --git a/src/@types/vscode.proposed.contribCommentPeekContext.d.ts b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts index 251df53c3a..f9784ad1d8 100644 --- a/src/@types/vscode.proposed.contribCommentPeekContext.d.ts +++ b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts @@ -1,10 +1,11 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// empty placeholder for comment peek context menus - -// https://github.com/microsoft/vscode/issues/151533 @alexr00 - - +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// empty placeholder for comment peek context menus + +// https://github.com/microsoft/vscode/issues/151533 @alexr00 + + diff --git a/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts index 2e71d90a25..d56831af42 100644 --- a/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts +++ b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts @@ -1,8 +1,9 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// empty placeholder for comment thread additional menus - -// https://github.com/microsoft/vscode/issues/163281 +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// empty placeholder for comment thread additional menus + +// https://github.com/microsoft/vscode/issues/163281 diff --git a/src/@types/vscode.proposed.contribShareMenu.d.ts b/src/@types/vscode.proposed.contribShareMenu.d.ts index e308029d4e..d00733d629 100644 --- a/src/@types/vscode.proposed.contribShareMenu.d.ts +++ b/src/@types/vscode.proposed.contribShareMenu.d.ts @@ -1,7 +1,8 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// empty placeholder declaration for the `file/share`-submenu contribution point -// https://github.com/microsoft/vscode/issues/176316 +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// empty placeholder declaration for the `file/share`-submenu contribution point +// https://github.com/microsoft/vscode/issues/176316 diff --git a/src/@types/vscode.proposed.diffCommand.d.ts b/src/@types/vscode.proposed.diffCommand.d.ts index 84f4328e07..6ab66baa56 100644 --- a/src/@types/vscode.proposed.diffCommand.d.ts +++ b/src/@types/vscode.proposed.diffCommand.d.ts @@ -1,38 +1,39 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/84899 - - /** - * The contiguous set of modified lines in a diff. - */ - export interface LineChange { - readonly originalStartLineNumber: number; - readonly originalEndLineNumber: number; - readonly modifiedStartLineNumber: number; - readonly modifiedEndLineNumber: number; - } - - export namespace commands { - - /** - * Registers a diff information command that can be invoked via a keyboard shortcut, - * a menu item, an action, or directly. - * - * Diff information commands are different from ordinary {@link commands.registerCommand commands} as - * they only execute when there is an active diff editor when the command is called, and the diff - * information has been computed. Also, the command handler of an editor command has access to - * the diff information. - * - * @param command A unique identifier for the command. - * @param callback A command handler function with access to the {@link LineChange diff information}. - * @param thisArg The `this` context used when invoking the handler function. - * @return Disposable which unregisters this command on disposal. - */ - export function registerDiffInformationCommand(command: string, callback: (diff: LineChange[], ...args: any[]) => any, thisArg?: any): Disposable; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/84899 + + /** + * The contiguous set of modified lines in a diff. + */ + export interface LineChange { + readonly originalStartLineNumber: number; + readonly originalEndLineNumber: number; + readonly modifiedStartLineNumber: number; + readonly modifiedEndLineNumber: number; + } + + export namespace commands { + + /** + * Registers a diff information command that can be invoked via a keyboard shortcut, + * a menu item, an action, or directly. + * + * Diff information commands are different from ordinary {@link commands.registerCommand commands} as + * they only execute when there is an active diff editor when the command is called, and the diff + * information has been computed. Also, the command handler of an editor command has access to + * the diff information. + * + * @param command A unique identifier for the command. + * @param callback A command handler function with access to the {@link LineChange diff information}. + * @param thisArg The `this` context used when invoking the handler function. + * @return Disposable which unregisters this command on disposal. + */ + export function registerDiffInformationCommand(command: string, callback: (diff: LineChange[], ...args: any[]) => any, thisArg?: any): Disposable; + } +} diff --git a/src/@types/vscode.proposed.fileComments.d.ts b/src/@types/vscode.proposed.fileComments.d.ts index 09c729145f..c7173df5f0 100644 --- a/src/@types/vscode.proposed.fileComments.d.ts +++ b/src/@types/vscode.proposed.fileComments.d.ts @@ -1,85 +1,86 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - export interface CommentThread2 { - /** - * The uri of the document the thread has been created on. - */ - readonly uri: Uri; - - /** - * The range the comment thread is located within the document. The thread icon will be shown - * at the last line of the range. - */ - range: Range | undefined; - - /** - * The ordered comments of the thread. - */ - comments: readonly Comment[]; - - /** - * Whether the thread should be collapsed or expanded when opening the document. - * Defaults to Collapsed. - */ - collapsibleState: CommentThreadCollapsibleState; - - /** - * Whether the thread supports reply. - * Defaults to true. - */ - canReply: boolean; - - /** - * Context value of the comment thread. This can be used to contribute thread specific actions. - * For example, a comment thread is given a context value as `editable`. When contributing actions to `comments/commentThread/title` - * using `menus` extension point, you can specify context value for key `commentThread` in `when` expression like `commentThread == editable`. - * ```json - * "contributes": { - * "menus": { - * "comments/commentThread/title": [ - * { - * "command": "extension.deleteCommentThread", - * "when": "commentThread == editable" - * } - * ] - * } - * } - * ``` - * This will show action `extension.deleteCommentThread` only for comment threads with `contextValue` is `editable`. - */ - contextValue?: string; - - /** - * The optional human-readable label describing the {@link CommentThread Comment Thread} - */ - label?: string; - - /** - * The optional state of a comment thread, which may affect how the comment is displayed. - */ - state?: CommentThreadState; - - /** - * Dispose this comment thread. - * - * Once disposed, this comment thread will be removed from visible editors and Comment Panel when appropriate. - */ - dispose(): void; - } - - export interface CommentController { - createCommentThread(uri: Uri, range: Range | undefined, comments: readonly Comment[]): CommentThread | CommentThread2; - } - - export interface CommentingRangeProvider2 { - /** - * Provide a list of ranges which allow new comment threads creation or null for a given document - */ - provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + export interface CommentThread2 { + /** + * The uri of the document the thread has been created on. + */ + readonly uri: Uri; + + /** + * The range the comment thread is located within the document. The thread icon will be shown + * at the last line of the range. + */ + range: Range | undefined; + + /** + * The ordered comments of the thread. + */ + comments: readonly Comment[]; + + /** + * Whether the thread should be collapsed or expanded when opening the document. + * Defaults to Collapsed. + */ + collapsibleState: CommentThreadCollapsibleState; + + /** + * Whether the thread supports reply. + * Defaults to true. + */ + canReply: boolean; + + /** + * Context value of the comment thread. This can be used to contribute thread specific actions. + * For example, a comment thread is given a context value as `editable`. When contributing actions to `comments/commentThread/title` + * using `menus` extension point, you can specify context value for key `commentThread` in `when` expression like `commentThread == editable`. + * ```json + * "contributes": { + * "menus": { + * "comments/commentThread/title": [ + * { + * "command": "extension.deleteCommentThread", + * "when": "commentThread == editable" + * } + * ] + * } + * } + * ``` + * This will show action `extension.deleteCommentThread` only for comment threads with `contextValue` is `editable`. + */ + contextValue?: string; + + /** + * The optional human-readable label describing the {@link CommentThread Comment Thread} + */ + label?: string; + + /** + * The optional state of a comment thread, which may affect how the comment is displayed. + */ + state?: CommentThreadState; + + /** + * Dispose this comment thread. + * + * Once disposed, this comment thread will be removed from visible editors and Comment Panel when appropriate. + */ + dispose(): void; + } + + export interface CommentController { + createCommentThread(uri: Uri, range: Range | undefined, comments: readonly Comment[]): CommentThread | CommentThread2; + } + + export interface CommentingRangeProvider2 { + /** + * Provide a list of ranges which allow new comment threads creation or null for a given document + */ + provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; + } +} diff --git a/src/@types/vscode.proposed.quickDiffProvider.d.ts b/src/@types/vscode.proposed.quickDiffProvider.d.ts index 3778e7092e..be79a258ca 100644 --- a/src/@types/vscode.proposed.quickDiffProvider.d.ts +++ b/src/@types/vscode.proposed.quickDiffProvider.d.ts @@ -1,17 +1,18 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/169012 - - export namespace window { - export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable; - } - - interface QuickDiffProvider { - label?: string; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/169012 + + export namespace window { + export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable; + } + + interface QuickDiffProvider { + label?: string; + } +} diff --git a/src/@types/vscode.proposed.readonlyMessage.d.ts b/src/@types/vscode.proposed.readonlyMessage.d.ts index 03bad3a664..6fef11ff7a 100644 --- a/src/@types/vscode.proposed.readonlyMessage.d.ts +++ b/src/@types/vscode.proposed.readonlyMessage.d.ts @@ -1,14 +1,15 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// https://github.com/microsoft/vscode/issues/166971 - -declare module 'vscode' { - - export namespace workspace { - - export function registerFileSystemProvider(scheme: string, provider: FileSystemProvider, options?: { readonly isCaseSensitive?: boolean; readonly isReadonly?: boolean | MarkdownString }): Disposable; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// https://github.com/microsoft/vscode/issues/166971 + +declare module 'vscode' { + + export namespace workspace { + + export function registerFileSystemProvider(scheme: string, provider: FileSystemProvider, options?: { readonly isCaseSensitive?: boolean; readonly isReadonly?: boolean | MarkdownString }): Disposable; + } +} diff --git a/src/@types/vscode.proposed.shareProvider.d.ts b/src/@types/vscode.proposed.shareProvider.d.ts index 8c432341a7..6a2ec4a28b 100644 --- a/src/@types/vscode.proposed.shareProvider.d.ts +++ b/src/@types/vscode.proposed.shareProvider.d.ts @@ -1,74 +1,75 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// https://github.com/microsoft/vscode/issues/176316 @joyceerhl - -declare module 'vscode' { - - /** - * Data about an item which can be shared. - */ - export interface ShareableItem { - /** - * A resource in the workspace that can be shared. - */ - resourceUri: Uri; - - /** - * If present, a selection within the `resourceUri`. - */ - selection?: Range; - } - - /** - * A provider which generates share links for resources in the editor. - */ - export interface ShareProvider { - - /** - * A unique ID for the provider. - * This will be used to activate specific extensions contributing share providers if necessary. - */ - readonly id: string; - - /** - * A label which will be used to present this provider's options in the UI. - */ - readonly label: string; - - /** - * The order in which the provider should be listed in the UI when there are multiple providers. - */ - readonly priority: number; - - /** - * - * @param item Data about an item which can be shared. - * @param token A cancellation token. - * @returns A {@link Uri} representing an external link or sharing text. The provider result - * will be copied to the user's clipboard and presented in a confirmation dialog. - */ - provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; - } - - export namespace window { - - /** - * Register a share provider. An extension may register multiple share providers. - * There may be multiple share providers for the same {@link ShareableItem}. - * @param selector A document selector to filter whether the provider should be shown for a {@link ShareableItem}. - * @param provider A share provider. - */ - export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; - } - - export interface TreeItem { - - /** - * An optional property which, when set, inlines a `Share` option in the context menu for this tree item. - */ - shareableItem?: ShareableItem; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// https://github.com/microsoft/vscode/issues/176316 @joyceerhl + +declare module 'vscode' { + + /** + * Data about an item which can be shared. + */ + export interface ShareableItem { + /** + * A resource in the workspace that can be shared. + */ + resourceUri: Uri; + + /** + * If present, a selection within the `resourceUri`. + */ + selection?: Range; + } + + /** + * A provider which generates share links for resources in the editor. + */ + export interface ShareProvider { + + /** + * A unique ID for the provider. + * This will be used to activate specific extensions contributing share providers if necessary. + */ + readonly id: string; + + /** + * A label which will be used to present this provider's options in the UI. + */ + readonly label: string; + + /** + * The order in which the provider should be listed in the UI when there are multiple providers. + */ + readonly priority: number; + + /** + * + * @param item Data about an item which can be shared. + * @param token A cancellation token. + * @returns A {@link Uri} representing an external link or sharing text. The provider result + * will be copied to the user's clipboard and presented in a confirmation dialog. + */ + provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; + } + + export namespace window { + + /** + * Register a share provider. An extension may register multiple share providers. + * There may be multiple share providers for the same {@link ShareableItem}. + * @param selector A document selector to filter whether the provider should be shown for a {@link ShareableItem}. + * @param provider A share provider. + */ + export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; + } + + export interface TreeItem { + + /** + * An optional property which, when set, inlines a `Share` option in the context menu for this tree item. + */ + shareableItem?: ShareableItem; + } +} diff --git a/src/@types/vscode.proposed.tokenInformation.d.ts b/src/@types/vscode.proposed.tokenInformation.d.ts index 1c783b2bdc..e82d667ac9 100644 --- a/src/@types/vscode.proposed.tokenInformation.d.ts +++ b/src/@types/vscode.proposed.tokenInformation.d.ts @@ -1,26 +1,27 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/91555 - - export enum StandardTokenType { - Other = 0, - Comment = 1, - String = 2, - RegEx = 3 - } - - export interface TokenInformation { - type: StandardTokenType; - range: Range; - } - - export namespace languages { - /** @deprecated */ - export function getTokenInformationAtPosition(document: TextDocument, position: Position): Thenable; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/91555 + + export enum StandardTokenType { + Other = 0, + Comment = 1, + String = 2, + RegEx = 3 + } + + export interface TokenInformation { + type: StandardTokenType; + range: Range; + } + + export namespace languages { + /** @deprecated */ + export function getTokenInformationAtPosition(document: TextDocument, position: Position): Thenable; + } +} diff --git a/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts index ad4655d9bc..27274764e9 100644 --- a/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts +++ b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts @@ -1,28 +1,29 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - export interface TreeView2 extends Disposable { - readonly onDidExpandElement: Event>; - readonly onDidCollapseElement: Event>; - readonly selection: readonly T[]; - readonly onDidChangeSelection: Event>; - readonly visible: boolean; - readonly onDidChangeVisibility: Event; - readonly onDidChangeCheckboxState: Event>; - title?: string; - description?: string; - badge?: ViewBadge | undefined; - reveal(element: T, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; - - /** - * An optional human-readable message that will be rendered in the view. - * Only a subset of markdown is supported. - * Setting the message to null, undefined, or empty string will remove the message from the view. - */ - message?: string | MarkdownString; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + export interface TreeView2 extends Disposable { + readonly onDidExpandElement: Event>; + readonly onDidCollapseElement: Event>; + readonly selection: readonly T[]; + readonly onDidChangeSelection: Event>; + readonly visible: boolean; + readonly onDidChangeVisibility: Event; + readonly onDidChangeCheckboxState: Event>; + title?: string; + description?: string; + badge?: ViewBadge | undefined; + reveal(element: T, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; + + /** + * An optional human-readable message that will be rendered in the view. + * Only a subset of markdown is supported. + * Setting the message to null, undefined, or empty string will remove the message from the view. + */ + message?: string | MarkdownString; + } +} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index 98c8fb8d2e..7ab5c93721 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -1,263 +1,265 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken, Disposable, Event, Uri } from 'vscode'; -import { APIState, PublishEvent } from '../@types/git'; - -export interface InputBox { - value: string; -} - -export { RefType } from './api1'; - -export interface Ref { - readonly type: RefType; - readonly name?: string; - readonly commit?: string; - readonly remote?: string; -} - -export interface UpstreamRef { - readonly remote: string; - readonly name: string; -} - -export interface Branch extends Ref { - readonly upstream?: UpstreamRef; - readonly ahead?: number; - readonly behind?: number; -} - -export interface Commit { - readonly hash: string; - readonly message: string; - readonly parents: string[]; - readonly authorDate?: Date; - readonly authorName?: string; - readonly authorEmail?: string; - readonly commitDate?: Date; -} - -export interface Submodule { - readonly name: string; - readonly path: string; - readonly url: string; -} - -export interface Remote { - readonly name: string; - readonly fetchUrl?: string; - readonly pushUrl?: string; - readonly isReadOnly: boolean; -} - -export { Status } from './api1'; - -export interface Change { - /** - * Returns either `originalUri` or `renameUri`, depending - * on whether this change is a rename change. When - * in doubt always use `uri` over the other two alternatives. - */ - readonly uri: Uri; - readonly originalUri: Uri; - readonly renameUri: Uri | undefined; - readonly status: Status; -} - -export interface RepositoryState { - readonly HEAD: Branch | undefined; - readonly remotes: Remote[]; - readonly submodules: Submodule[]; - readonly rebaseCommit: Commit | undefined; - - readonly mergeChanges: Change[]; - readonly indexChanges: Change[]; - readonly workingTreeChanges: Change[]; - - readonly onDidChange: Event; -} - -export interface RepositoryUIState { - readonly selected: boolean; - readonly onDidChange: Event; -} - -export interface CommitOptions { - all?: boolean | 'tracked'; - amend?: boolean; - signoff?: boolean; - signCommit?: boolean; - empty?: boolean; -} - -export interface FetchOptions { - remote?: string; - ref?: string; - all?: boolean; - prune?: boolean; - depth?: number; -} - -export interface RefQuery { - readonly contains?: string; - readonly count?: number; - readonly pattern?: string; - readonly sort?: 'alphabetically' | 'committerdate'; -} - -export interface BranchQuery extends RefQuery { - readonly remote?: boolean; -} - -export interface Repository { - readonly inputBox: InputBox; - readonly rootUri: Uri; - readonly state: RepositoryState; - readonly ui: RepositoryUIState; - - /** - * GH PR saves pull request related information to git config when users checkout a pull request. - * There are two mandatory config for a branch - * 1. `remote`, which refers to the related github repository - * 2. `github-pr-owner-number`, which refers to the related pull request - * - * There is one optional config for a remote - * 1. `github-pr-remote`, which indicates if the remote is created particularly for GH PR review. By default, GH PR won't load pull requests from remotes created by itself (`github-pr-remote=true`). - * - * Sample config: - * ```git - * [remote "pr"] - * url = https://github.com/pr/vscode-pull-request-github - * fetch = +refs/heads/*:refs/remotes/pr/* - * github-pr-remote = true - * [branch "fix-123"] - * remote = pr - * merge = refs/heads/fix-123 - * github-pr-owner-number = "Microsoft#vscode-pull-request-github#123" - * ``` - */ - getConfigs(): Promise<{ key: string; value: string }[]>; - - /** - * Git providers are recommended to implement a minimal key value lookup for git config but you can only provide config for following keys to activate GH PR successfully - * 1. `branch.${branchName}.github-pr-owner-number` - * 2. `remote.${remoteName}.github-pr-remote` - * 3. `branch.${branchName}.remote` - */ - getConfig(key: string): Promise; - - /** - * The counterpart of `getConfig` - */ - setConfig(key: string, value: string): Promise; - getGlobalConfig(key: string): Promise; - - getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }>; - detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }>; - buffer(ref: string, path: string): Promise; - show(ref: string, path: string): Promise; - getCommit(ref: string): Promise; - - clean(paths: string[]): Promise; - - apply(patch: string, reverse?: boolean): Promise; - diff(cached?: boolean): Promise; - diffWithHEAD(): Promise; - diffWithHEAD(path: string): Promise; - diffWith(ref: string): Promise; - diffWith(ref: string, path: string): Promise; - diffIndexWithHEAD(): Promise; - diffIndexWithHEAD(path: string): Promise; - diffIndexWith(ref: string): Promise; - diffIndexWith(ref: string, path: string): Promise; - diffBlobs(object1: string, object2: string): Promise; - diffBetween(ref1: string, ref2: string): Promise; - diffBetween(ref1: string, ref2: string, path: string): Promise; - - hashObject(data: string): Promise; - - createBranch(name: string, checkout: boolean, ref?: string): Promise; - deleteBranch(name: string, force?: boolean): Promise; - getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; - setBranchUpstream(name: string, upstream: string): Promise; - getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; - - getMergeBase(ref1: string, ref2: string): Promise; - - status(): Promise; - checkout(treeish: string): Promise; - - addRemote(name: string, url: string): Promise; - removeRemote(name: string): Promise; - renameRemote(name: string, newName: string): Promise; - - fetch(options?: FetchOptions): Promise; - fetch(remote?: string, ref?: string, depth?: number): Promise; - pull(unshallow?: boolean): Promise; - push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; - - blame(path: string): Promise; - log(options?: LogOptions): Promise; - - commit(message: string, opts?: CommitOptions): Promise; - add(paths: string[]): Promise; -} - -/** - * Log options. - */ -export interface LogOptions { - /** Max number of log entries to retrieve. If not specified, the default is 32. */ - readonly maxEntries?: number; - readonly path?: string; - /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ - readonly range?: string; -} - -export interface PostCommitCommandsProvider { - getCommands(repository: Repository): Command[]; -} - -export { GitErrorCodes } from './api1'; - -export interface IGit { - readonly repositories: Repository[]; - readonly onDidOpenRepository: Event; - readonly onDidCloseRepository: Event; - - // Used by the actual git extension to indicate it has finished initializing state information - readonly state?: APIState; - readonly onDidChangeState?: Event; - readonly onDidPublish?: Event; - - registerPostCommitCommandsProvider?(provider: PostCommitCommandsProvider): Disposable; -} - -export interface TitleAndDescriptionProvider { - provideTitleAndDescription(commitMessages: string[], patches: string[], token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; - provideTitleAndDescription(context: { commitMessages: string[], patches: string[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; -} - -export interface API { - /** - * Register a [git provider](#IGit) - */ - registerGitProvider(provider: IGit): Disposable; - - /** - * Returns the [git provider](#IGit) that contains a given uri. - * - * @param uri An uri. - * @return A git provider or `undefined` - */ - getGitProvider(uri: Uri): IGit | undefined; - - /** - * Register a PR title and description provider. - */ - registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): Disposable; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { CancellationToken, Disposable, Event, Uri } from 'vscode'; +import { APIState, PublishEvent } from '../@types/git'; + +export interface InputBox { + value: string; +} + + +export { RefType } from './api1'; + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export { Status } from './api1'; + +export interface Change { + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + readonly inputBox: InputBox; + readonly rootUri: Uri; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + /** + * GH PR saves pull request related information to git config when users checkout a pull request. + * There are two mandatory config for a branch + * 1. `remote`, which refers to the related github repository + * 2. `github-pr-owner-number`, which refers to the related pull request + * + * There is one optional config for a remote + * 1. `github-pr-remote`, which indicates if the remote is created particularly for GH PR review. By default, GH PR won't load pull requests from remotes created by itself (`github-pr-remote=true`). + * + * Sample config: + * ```git + * [remote "pr"] + * url = https://github.com/pr/vscode-pull-request-github + * fetch = +refs/heads/*:refs/remotes/pr/* + * github-pr-remote = true + * [branch "fix-123"] + * remote = pr + * merge = refs/heads/fix-123 + * github-pr-owner-number = "Microsoft#vscode-pull-request-github#123" + * ``` + */ + getConfigs(): Promise<{ key: string; value: string }[]>; + + /** + * Git providers are recommended to implement a minimal key value lookup for git config but you can only provide config for following keys to activate GH PR successfully + * 1. `branch.${branchName}.github-pr-owner-number` + * 2. `remote.${remoteName}.github-pr-remote` + * 3. `branch.${branchName}.remote` + */ + getConfig(key: string): Promise; + + /** + * The counterpart of `getConfig` + */ + setConfig(key: string, value: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; + add(paths: string[]): Promise; +} + +/** + * Log options. + */ +export interface LogOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; + readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +export { GitErrorCodes } from './api1'; + +export interface IGit { + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + // Used by the actual git extension to indicate it has finished initializing state information + readonly state?: APIState; + readonly onDidChangeState?: Event; + readonly onDidPublish?: Event; + + registerPostCommitCommandsProvider?(provider: PostCommitCommandsProvider): Disposable; +} + +export interface TitleAndDescriptionProvider { + provideTitleAndDescription(commitMessages: string[], patches: string[], token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; + provideTitleAndDescription(context: { commitMessages: string[], patches: string[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; +} + +export interface API { + /** + * Register a [git provider](#IGit) + */ + registerGitProvider(provider: IGit): Disposable; + + /** + * Returns the [git provider](#IGit) that contains a given uri. + * + * @param uri An uri. + * @return A git provider or `undefined` + */ + getGitProvider(uri: Uri): IGit | undefined; + + /** + * Register a PR title and description provider. + */ + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): Disposable; +} diff --git a/src/api/api1.ts b/src/api/api1.ts index 84450bff55..275b2bc202 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -1,218 +1,220 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { APIState, PublishEvent } from '../@types/git'; -import Logger from '../common/logger'; -import { TernarySearchTree } from '../common/utils'; -import { API, IGit, PostCommitCommandsProvider, Repository, TitleAndDescriptionProvider } from './api'; - -export const enum RefType { - Head, - RemoteHead, - Tag, -} - -export const enum GitErrorCodes { - BadConfigFile = 'BadConfigFile', - AuthenticationFailed = 'AuthenticationFailed', - NoUserNameConfigured = 'NoUserNameConfigured', - NoUserEmailConfigured = 'NoUserEmailConfigured', - NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', - NotAGitRepository = 'NotAGitRepository', - NotAtRepositoryRoot = 'NotAtRepositoryRoot', - Conflict = 'Conflict', - StashConflict = 'StashConflict', - UnmergedChanges = 'UnmergedChanges', - PushRejected = 'PushRejected', - RemoteConnectionError = 'RemoteConnectionError', - DirtyWorkTree = 'DirtyWorkTree', - CantOpenResource = 'CantOpenResource', - GitNotFound = 'GitNotFound', - CantCreatePipe = 'CantCreatePipe', - CantAccessRemote = 'CantAccessRemote', - RepositoryNotFound = 'RepositoryNotFound', - RepositoryIsLocked = 'RepositoryIsLocked', - BranchNotFullyMerged = 'BranchNotFullyMerged', - NoRemoteReference = 'NoRemoteReference', - InvalidBranchName = 'InvalidBranchName', - BranchAlreadyExists = 'BranchAlreadyExists', - NoLocalChanges = 'NoLocalChanges', - NoStashFound = 'NoStashFound', - LocalChangesOverwritten = 'LocalChangesOverwritten', - NoUpstreamBranch = 'NoUpstreamBranch', - IsInSubmodule = 'IsInSubmodule', - WrongCase = 'WrongCase', - CantLockRef = 'CantLockRef', - CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', - PatchDoesNotApply = 'PatchDoesNotApply', -} - -export const enum Status { - INDEX_MODIFIED, - INDEX_ADDED, - INDEX_DELETED, - INDEX_RENAMED, - INDEX_COPIED, - - MODIFIED, - DELETED, - UNTRACKED, - IGNORED, - INTENT_TO_ADD, - - ADDED_BY_US, - ADDED_BY_THEM, - DELETED_BY_US, - DELETED_BY_THEM, - BOTH_ADDED, - BOTH_DELETED, - BOTH_MODIFIED, -} - -export class GitApiImpl implements API, IGit, vscode.Disposable { - private static _handlePool: number = 0; - private _providers = new Map(); - - public get repositories(): Repository[] { - const ret: Repository[] = []; - - this._providers.forEach(({ repositories }) => { - if (repositories) { - ret.push(...repositories); - } - }); - - return ret; - } - - public get state(): APIState | undefined { - if (this._providers.size === 0) { - return undefined; - } - - for (const [, { state }] of this._providers) { - if (state !== 'initialized') { - return 'uninitialized'; - } - } - - return 'initialized'; - } - - private _onDidOpenRepository = new vscode.EventEmitter(); - readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; - private _onDidCloseRepository = new vscode.EventEmitter(); - readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; - private _onDidChangeState = new vscode.EventEmitter(); - readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; - private _onDidPublish = new vscode.EventEmitter(); - readonly onDidPublish: vscode.Event = this._onDidPublish.event; - - private _disposables: vscode.Disposable[]; - constructor() { - this._disposables = []; - } - - private _updateReposContext() { - const reposCount = Array.from(this._providers.values()).reduce((prev, current) => { - return prev + current.repositories.length; - }, 0); - vscode.commands.executeCommand('setContext', 'gitHubOpenRepositoryCount', reposCount); - } - - registerGitProvider(provider: IGit): vscode.Disposable { - Logger.appendLine(`Registering git provider`); - const handle = this._nextHandle(); - this._providers.set(handle, provider); - - this._disposables.push(provider.onDidCloseRepository(e => this._onDidCloseRepository.fire(e))); - this._disposables.push(provider.onDidOpenRepository(e => { - Logger.appendLine(`Repository ${e.rootUri} has been opened`); - this._updateReposContext(); - this._onDidOpenRepository.fire(e); - })); - if (provider.onDidChangeState) { - this._disposables.push(provider.onDidChangeState(e => this._onDidChangeState.fire(e))); - } - if (provider.onDidPublish) { - this._disposables.push(provider.onDidPublish(e => this._onDidPublish.fire(e))); - } - - this._updateReposContext(); - provider.repositories.forEach(repository => { - this._onDidOpenRepository.fire(repository); - }); - - return { - dispose: () => { - const repos = provider?.repositories; - if (repos && repos.length > 0) { - repos.forEach(r => this._onDidCloseRepository.fire(r)); - } - this._providers.delete(handle); - }, - }; - } - - getGitProvider(uri: vscode.Uri): IGit | undefined { - const foldersMap = TernarySearchTree.forUris(); - - this._providers.forEach(provider => { - const repos = provider.repositories; - if (repos && repos.length > 0) { - for (const repository of repos) { - foldersMap.set(repository.rootUri, provider); - } - } - }); - - return foldersMap.findSubstr(uri); - } - - registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): vscode.Disposable { - const disposables = Array.from(this._providers.values()).map(gitProvider => { - if (gitProvider.registerPostCommitCommandsProvider) { - return gitProvider.registerPostCommitCommandsProvider(provider); - } - return { dispose: () => { } }; - }); - return { - dispose: () => disposables.forEach(disposable => disposable.dispose()) - }; - } - - private _nextHandle(): number { - return GitApiImpl._handlePool++; - } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } - - private _titleAndDescriptionProviders: Set<{ title: string, provider: TitleAndDescriptionProvider }> = new Set(); - registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): vscode.Disposable { - const registeredValue = { title, provider }; - this._titleAndDescriptionProviders.add(registeredValue); - const disposable = { - dispose: () => this._titleAndDescriptionProviders.delete(registeredValue) - }; - this._disposables.push(disposable); - return disposable; - } - - getTitleAndDescriptionProvider(searchTerm?: string): { title: string, provider: TitleAndDescriptionProvider } | undefined { - if (!searchTerm) { - return this._titleAndDescriptionProviders.size > 0 ? this._titleAndDescriptionProviders.values().next().value : undefined; - } else { - for (const provider of this._titleAndDescriptionProviders) { - if (provider.title.toLowerCase().includes(searchTerm.toLowerCase())) { - return provider; - } - } - } - } - -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { APIState, PublishEvent } from '../@types/git'; +import Logger from '../common/logger'; +import { TernarySearchTree } from '../common/utils'; +import { API, IGit, PostCommitCommandsProvider, Repository, TitleAndDescriptionProvider } from './api'; + +export const enum RefType { + Head, + RemoteHead, + Tag, + +} + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + +export class GitApiImpl implements API, IGit, vscode.Disposable { + private static _handlePool: number = 0; + private _providers = new Map(); + + public get repositories(): Repository[] { + const ret: Repository[] = []; + + this._providers.forEach(({ repositories }) => { + if (repositories) { + ret.push(...repositories); + } + }); + + return ret; + } + + public get state(): APIState | undefined { + if (this._providers.size === 0) { + return undefined; + } + + for (const [, { state }] of this._providers) { + if (state !== 'initialized') { + return 'uninitialized'; + } + } + + return 'initialized'; + } + + private _onDidOpenRepository = new vscode.EventEmitter(); + readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; + private _onDidCloseRepository = new vscode.EventEmitter(); + readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; + private _onDidChangeState = new vscode.EventEmitter(); + readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + private _onDidPublish = new vscode.EventEmitter(); + readonly onDidPublish: vscode.Event = this._onDidPublish.event; + + private _disposables: vscode.Disposable[]; + constructor() { + this._disposables = []; + } + + private _updateReposContext() { + const reposCount = Array.from(this._providers.values()).reduce((prev, current) => { + return prev + current.repositories.length; + }, 0); + vscode.commands.executeCommand('setContext', 'gitHubOpenRepositoryCount', reposCount); + } + + registerGitProvider(provider: IGit): vscode.Disposable { + Logger.appendLine(`Registering git provider`); + const handle = this._nextHandle(); + this._providers.set(handle, provider); + + this._disposables.push(provider.onDidCloseRepository(e => this._onDidCloseRepository.fire(e))); + this._disposables.push(provider.onDidOpenRepository(e => { + Logger.appendLine(`Repository ${e.rootUri} has been opened`); + this._updateReposContext(); + this._onDidOpenRepository.fire(e); + })); + if (provider.onDidChangeState) { + this._disposables.push(provider.onDidChangeState(e => this._onDidChangeState.fire(e))); + } + if (provider.onDidPublish) { + this._disposables.push(provider.onDidPublish(e => this._onDidPublish.fire(e))); + } + + this._updateReposContext(); + provider.repositories.forEach(repository => { + this._onDidOpenRepository.fire(repository); + }); + + return { + dispose: () => { + const repos = provider?.repositories; + if (repos && repos.length > 0) { + repos.forEach(r => this._onDidCloseRepository.fire(r)); + } + this._providers.delete(handle); + }, + }; + } + + getGitProvider(uri: vscode.Uri): IGit | undefined { + const foldersMap = TernarySearchTree.forUris(); + + this._providers.forEach(provider => { + const repos = provider.repositories; + if (repos && repos.length > 0) { + for (const repository of repos) { + foldersMap.set(repository.rootUri, provider); + } + } + }); + + return foldersMap.findSubstr(uri); + } + + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): vscode.Disposable { + const disposables = Array.from(this._providers.values()).map(gitProvider => { + if (gitProvider.registerPostCommitCommandsProvider) { + return gitProvider.registerPostCommitCommandsProvider(provider); + } + return { dispose: () => { } }; + }); + return { + dispose: () => disposables.forEach(disposable => disposable.dispose()) + }; + } + + private _nextHandle(): number { + return GitApiImpl._handlePool++; + } + + dispose() { + this._disposables.forEach(disposable => disposable.dispose()); + } + + private _titleAndDescriptionProviders: Set<{ title: string, provider: TitleAndDescriptionProvider }> = new Set(); + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): vscode.Disposable { + const registeredValue = { title, provider }; + this._titleAndDescriptionProviders.add(registeredValue); + const disposable = { + dispose: () => this._titleAndDescriptionProviders.delete(registeredValue) + }; + this._disposables.push(disposable); + return disposable; + } + + getTitleAndDescriptionProvider(searchTerm?: string): { title: string, provider: TitleAndDescriptionProvider } | undefined { + if (!searchTerm) { + return this._titleAndDescriptionProviders.size > 0 ? this._titleAndDescriptionProviders.values().next().value : undefined; + } else { + for (const provider of this._titleAndDescriptionProviders) { + if (provider.title.toLowerCase().includes(searchTerm.toLowerCase())) { + return provider; + } + } + } + } + +} diff --git a/src/authentication/configuration.ts b/src/authentication/configuration.ts index 0f48622c76..03e0cbf07d 100644 --- a/src/authentication/configuration.ts +++ b/src/authentication/configuration.ts @@ -1,51 +1,53 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; - -export interface IHostConfiguration { - host: string; - token: string | undefined; -} - -let USE_TEST_SERVER = false; - -export const HostHelper = class { - public static async getApiHost(host: IHostConfiguration | vscode.Uri): Promise { - const testEnv = process.env.GITHUB_TEST_SERVER; - if (testEnv) { - if (USE_TEST_SERVER) { - return vscode.Uri.parse(testEnv); - } - - const yes = vscode.l10n.t('Yes'); - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('The \'GITHUB_TEST_SERVER\' environment variable is set to \'{0}\'. Use this as the GitHub API endpoint?', testEnv), - { modal: true }, - yes, - ); - if (result === yes) { - USE_TEST_SERVER = true; - return vscode.Uri.parse(testEnv); - } - } - - const hostUri: vscode.Uri = host instanceof vscode.Uri ? host : vscode.Uri.parse(host.host); - if (hostUri.authority === 'github.com') { - return vscode.Uri.parse('https://api.github.com'); - } else { - return vscode.Uri.parse(`${hostUri.scheme}://${hostUri.authority}`); - } - } - - public static getApiPath(host: IHostConfiguration | vscode.Uri, path: string): string { - const hostUri: vscode.Uri = host instanceof vscode.Uri ? host : vscode.Uri.parse(host.host); - if (hostUri.authority === 'github.com') { - return path; - } else { - return `/api/v3${path}`; - } - } -}; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; + +export interface IHostConfiguration { + host: string; + token: string | undefined; +} + +let USE_TEST_SERVER = fals +e; + +export const HostHelper = class { + public static async getApiHost(host: IHostConfiguration | vscode.Uri): Promise { + const testEnv = process.env.GITHUB_TEST_SERVER; + if (testEnv) { + if (USE_TEST_SERVER) { + return vscode.Uri.parse(testEnv); + } + + const yes = vscode.l10n.t('Yes'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('The \'GITHUB_TEST_SERVER\' environment variable is set to \'{0}\'. Use this as the GitHub API endpoint?', testEnv), + { modal: true }, + yes, + ); + if (result === yes) { + USE_TEST_SERVER = true; + return vscode.Uri.parse(testEnv); + } + } + + const hostUri: vscode.Uri = host instanceof vscode.Uri ? host : vscode.Uri.parse(host.host); + if (hostUri.authority === 'github.com') { + return vscode.Uri.parse('https://api.github.com'); + } else { + return vscode.Uri.parse(`${hostUri.scheme}://${hostUri.authority}`); + } + } + + public static getApiPath(host: IHostConfiguration | vscode.Uri, path: string): string { + const hostUri: vscode.Uri = host instanceof vscode.Uri ? host : vscode.Uri.parse(host.host); + if (hostUri.authority === 'github.com') { + return path; + } else { + return `/api/v3${path}`; + } + } +}; diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 6f9dd03ffa..2cc0e38365 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -1,122 +1,124 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import fetch from 'cross-fetch'; -import * as vscode from 'vscode'; -import { GitHubServerType } from '../common/authentication'; -import Logger from '../common/logger'; -import { agent } from '../env/node/net'; -import { getEnterpriseUri } from '../github/utils'; -import { HostHelper } from './configuration'; - -export class GitHubManager { - private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); - private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com'); - private _servers: Map = new Map(Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom])); - - public static isGithubDotCom(host: string): boolean { - return this._githubDotComServers.has(host); - } - - public static isNeverGitHub(host: string): boolean { - return this._neverGitHubServers.has(host); - } - - public async isGitHub(host: vscode.Uri): Promise { - if (host === null) { - return GitHubServerType.None; - } - - // .wiki/.git repos are not supported - if (host.path.endsWith('.wiki') || host.authority.match(/gist[.]github[.]com/)) { - return GitHubServerType.None; - } - - if (GitHubManager.isGithubDotCom(host.authority)) { - return GitHubServerType.GitHubDotCom; - } - - const knownEnterprise = getEnterpriseUri(); - if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!this._servers.has(host.authority) || (this._servers.get(host.authority) === GitHubServerType.None))) { - return GitHubServerType.Enterprise; - } - - if (this._servers.has(host.authority)) { - return this._servers.get(host.authority) ?? GitHubServerType.None; - } - - const [uri, options] = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); - - let isGitHub = GitHubServerType.None; - try { - const response = await fetch(uri.toString(), options); - const otherGitHubHeaders: string[] = []; - response.headers.forEach((_value, header) => { - otherGitHubHeaders.push(header); - }); - Logger.debug(`All headers: ${otherGitHubHeaders.join(', ')}`, 'GitHubServer'); - const gitHubHeader = response.headers.get('x-github-request-id'); - const gitHubEnterpriseHeader = response.headers.get('x-github-enterprise-version'); - if (!gitHubHeader && !gitHubEnterpriseHeader) { - const [uriFallBack] = await GitHubManager.getOptions(host, 'HEAD', '/status'); - const response = await fetch(uriFallBack.toString()); - const responseText = await response.text(); - if (responseText.startsWith('GitHub lives!')) { - // We've made it this far so it's not github.com - // It's very likely enterprise. - isGitHub = GitHubServerType.Enterprise; - } else { - // Check if we got an enterprise-looking needs auth response: - // { message: 'Must authenticate to access this API.', documentation_url: 'https://docs.github.com/enterprise/3.3/rest'} - Logger.appendLine(`Received fallback response from the server: ${responseText}`, 'GitHubServer'); - const parsedResponse = JSON.parse(responseText); - if (parsedResponse.documentation_url && (parsedResponse.documentation_url as string).startsWith('https://docs.github.com/enterprise')) { - isGitHub = GitHubServerType.Enterprise; - } - } - } else { - isGitHub = ((gitHubHeader !== undefined) && (gitHubHeader !== null)) ? (gitHubEnterpriseHeader ? GitHubServerType.Enterprise : GitHubServerType.GitHubDotCom) : GitHubServerType.None; - } - return isGitHub; - } catch (ex) { - Logger.warn(`No response from host ${host}: ${ex.message}`, 'GitHubServer'); - return isGitHub; - } finally { - Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); - this._servers.set(host.authority, isGitHub); - } - } - - public static async getOptions( - hostUri: vscode.Uri, - method: string = 'GET', - path: string, - token?: string, - ): Promise<[vscode.Uri, RequestInit]> { - const headers: { - 'user-agent': string; - authorization?: string; - } = { - 'user-agent': 'GitHub VSCode Pull Requests', - }; - if (token) { - headers.authorization = `token ${token}`; - } - - const uri = vscode.Uri.joinPath(await HostHelper.getApiHost(hostUri), HostHelper.getApiPath(hostUri, path)); - const requestInit = { - hostname: uri.authority, - port: 443, - method, - headers, - agent - }; - - return [ - uri, - requestInit as RequestInit, - ]; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import fetch from 'cross-fetch'; +import * as vscode from 'vscode'; +import { GitHubServerType } from '../common/authentication'; +import Logger from '../common/logger'; +import { agent } from '../env/node/net'; +import { getEnterpriseUri } from '../github/utils'; +import { HostHelper } from './configuration'; + + +export class GitHubManager { + private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); + private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com'); + private _servers: Map = new Map(Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom])); + + public static isGithubDotCom(host: string): boolean { + return this._githubDotComServers.has(host); + } + + public static isNeverGitHub(host: string): boolean { + return this._neverGitHubServers.has(host); + } + + public async isGitHub(host: vscode.Uri): Promise { + if (host === null) { + return GitHubServerType.None; + } + + // .wiki/.git repos are not supported + if (host.path.endsWith('.wiki') || host.authority.match(/gist[.]github[.]com/)) { + return GitHubServerType.None; + } + + if (GitHubManager.isGithubDotCom(host.authority)) { + return GitHubServerType.GitHubDotCom; + } + + const knownEnterprise = getEnterpriseUri(); + if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!this._servers.has(host.authority) || (this._servers.get(host.authority) === GitHubServerType.None))) { + return GitHubServerType.Enterprise; + } + + if (this._servers.has(host.authority)) { + return this._servers.get(host.authority) ?? GitHubServerType.None; + } + + const [uri, options] = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); + + let isGitHub = GitHubServerType.None; + try { + const response = await fetch(uri.toString(), options); + const otherGitHubHeaders: string[] = []; + response.headers.forEach((_value, header) => { + otherGitHubHeaders.push(header); + }); + Logger.debug(`All headers: ${otherGitHubHeaders.join(', ')}`, 'GitHubServer'); + const gitHubHeader = response.headers.get('x-github-request-id'); + const gitHubEnterpriseHeader = response.headers.get('x-github-enterprise-version'); + if (!gitHubHeader && !gitHubEnterpriseHeader) { + const [uriFallBack] = await GitHubManager.getOptions(host, 'HEAD', '/status'); + const response = await fetch(uriFallBack.toString()); + const responseText = await response.text(); + if (responseText.startsWith('GitHub lives!')) { + // We've made it this far so it's not github.com + // It's very likely enterprise. + isGitHub = GitHubServerType.Enterprise; + } else { + // Check if we got an enterprise-looking needs auth response: + // { message: 'Must authenticate to access this API.', documentation_url: 'https://docs.github.com/enterprise/3.3/rest'} + Logger.appendLine(`Received fallback response from the server: ${responseText}`, 'GitHubServer'); + const parsedResponse = JSON.parse(responseText); + if (parsedResponse.documentation_url && (parsedResponse.documentation_url as string).startsWith('https://docs.github.com/enterprise')) { + isGitHub = GitHubServerType.Enterprise; + } + } + } else { + isGitHub = ((gitHubHeader !== undefined) && (gitHubHeader !== null)) ? (gitHubEnterpriseHeader ? GitHubServerType.Enterprise : GitHubServerType.GitHubDotCom) : GitHubServerType.None; + } + return isGitHub; + } catch (ex) { + Logger.warn(`No response from host ${host}: ${ex.message}`, 'GitHubServer'); + return isGitHub; + } finally { + Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); + this._servers.set(host.authority, isGitHub); + } + } + + public static async getOptions( + hostUri: vscode.Uri, + method: string = 'GET', + path: string, + token?: string, + ): Promise<[vscode.Uri, RequestInit]> { + const headers: { + 'user-agent': string; + authorization?: string; + } = { + 'user-agent': 'GitHub VSCode Pull Requests', + }; + if (token) { + headers.authorization = `token ${token}`; + } + + const uri = vscode.Uri.joinPath(await HostHelper.getApiHost(hostUri), HostHelper.getApiPath(hostUri, path)); + const requestInit = { + hostname: uri.authority, + port: 443, + method, + headers, + agent + }; + + return [ + uri, + requestInit as RequestInit, + ]; + } +} diff --git a/src/commands.ts b/src/commands.ts index 725a2b995a..b7daaa5e85 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,1458 +1,1459 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as pathLib from 'path'; -import * as vscode from 'vscode'; -import { Repository } from './api/api'; -import { GitErrorCodes } from './api/api1'; -import { CommentReply, resolveCommentHandler } from './commentHandlerResolver'; -import { IComment } from './common/comment'; -import Logger from './common/logger'; -import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; -import { ITelemetry } from './common/telemetry'; -import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './common/uri'; -import { formatError } from './common/utils'; -import { EXTENSION_ID } from './constants'; -import { FolderRepositoryManager } from './github/folderRepositoryManager'; -import { GitHubRepository } from './github/githubRepository'; -import { PullRequest } from './github/interface'; -import { NotificationProvider } from './github/notifications'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; -import { PullRequestModel } from './github/pullRequestModel'; -import { PullRequestOverviewPanel } from './github/pullRequestOverview'; -import { RepositoriesManager } from './github/repositoriesManager'; -import { getIssuesUrl, getPullsUrl, isInCodespaces, vscodeDevPrLink } from './github/utils'; -import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; -import { ReviewCommentController } from './view/reviewCommentController'; -import { ReviewManager } from './view/reviewManager'; -import { ReviewsManager } from './view/reviewsManager'; -import { CategoryTreeNode } from './view/treeNodes/categoryNode'; -import { CommitNode } from './view/treeNodes/commitNode'; -import { DescriptionNode } from './view/treeNodes/descriptionNode'; -import { - FileChangeNode, - GitFileChangeNode, - InMemFileChangeNode, - openFileCommand, - RemoteFileChangeNode, -} from './view/treeNodes/fileChangeNode'; -import { PRNode } from './view/treeNodes/pullRequestNode'; -import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; - -const _onDidUpdatePR = new vscode.EventEmitter(); -export const onDidUpdatePR: vscode.Event = _onDidUpdatePR.event; - -function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | PullRequestModel): PullRequestModel { - // If the command is called from the command palette, no arguments are passed. - if (!pr) { - if (!folderRepoManager.activePullRequest) { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to find current pull request.')); - throw new Error('Unable to find current pull request.'); - } - - return folderRepoManager.activePullRequest; - } else { - return pr instanceof PRNode ? pr.pullRequestModel : pr; - } -} - -export async function openDescription( - context: vscode.ExtensionContext, - telemetry: ITelemetry, - pullRequestModel: PullRequestModel, - descriptionNode: DescriptionNode | undefined, - folderManager: FolderRepositoryManager, - revealNode: boolean, - preserveFocus: boolean = true, - notificationProvider?: NotificationProvider -) { - const pullRequest = ensurePR(folderManager, pullRequestModel); - if (revealNode) { - descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); - } - // Create and show a new webview - await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); - - if (notificationProvider?.hasNotification(pullRequest)) { - notificationProvider.markPrNotificationsAsRead(pullRequest); - } - - /* __GDPR__ - "pr.openDescription" : {} - */ - telemetry.sendTelemetryEvent('pr.openDescription'); -} - -async function chooseItem( - activePullRequests: T[], - propertyGetter: (itemValue: T) => string, - options?: vscode.QuickPickOptions, -): Promise { - if (activePullRequests.length === 1) { - return activePullRequests[0]; - } - interface Item extends vscode.QuickPickItem { - itemValue: T; - } - const items: Item[] = activePullRequests.map(currentItem => { - return { - label: propertyGetter(currentItem), - itemValue: currentItem, - }; - }); - return (await vscode.window.showQuickPick(items, options))?.itemValue; -} - -export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | PullRequestModel, telemetry: ITelemetry) { - if (e instanceof PRNode || e instanceof DescriptionNode) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.pullRequestModel.html_url)); - } else { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.html_url)); - } - - /** __GDPR__ - "pr.openInGitHub" : {} - */ - telemetry.sendTelemetryEvent('pr.openInGitHub'); -} - -export function registerCommands( - context: vscode.ExtensionContext, - reposManager: RepositoriesManager, - reviewsManager: ReviewsManager, - telemetry: ITelemetry, - tree: PullRequestsTreeDataProvider -) { - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openPullRequestOnGitHub', - async (e: PRNode | DescriptionNode | PullRequestModel | undefined) => { - if (!e) { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR); - - if (activePullRequests.length >= 1) { - const result = await chooseItem( - activePullRequests, - itemValue => itemValue.html_url, - ); - if (result) { - openPullRequestOnGitHub(result, telemetry); - } - } - } else { - openPullRequestOnGitHub(e, telemetry); - } - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openAllDiffs', - async () => { - const activePullRequestsWithFolderManager = reposManager.folderManagers - .filter(folderManager => folderManager.activePullRequest) - .map(folderManager => { - return (({ activePr: folderManager.activePullRequest!, folderManager })); - }); - - const activePullRequestAndFolderManager = activePullRequestsWithFolderManager.length >= 1 - ? ( - await chooseItem( - activePullRequestsWithFolderManager, - itemValue => itemValue.activePr.html_url, - ) - ) - : activePullRequestsWithFolderManager[0]; - - if (!activePullRequestAndFolderManager) { - return; - } - - const { folderManager } = activePullRequestAndFolderManager; - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); - - if (!reviewManager) { - return; - } - - reviewManager.reviewModel.localFileChanges - .forEach(localFileChange => localFileChange.openDiff(folderManager, { preview: false })); - } - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('review.suggestDiff', async e => { - const hasShownMessageKey = 'githubPullRequest.suggestDiffMessage'; - const hasShownMessage = context.globalState.get(hasShownMessageKey, false); - if (!hasShownMessage) { - await context.globalState.update(hasShownMessageKey, true); - const documentation = vscode.l10n.t('Open documentation'); - const result = await vscode.window.showInformationMessage(vscode.l10n.t('You can now make suggestions from review comments, just like on GitHub.com. See the documentation for more details.'), - { modal: true }, documentation); - if (result === documentation) { - return vscode.env.openExternal(vscode.Uri.parse('https://github.com/microsoft/vscode-pull-request-github/blob/main/documentation/suggestAChange.md')); - } - } - try { - const folderManager = await chooseItem( - reposManager.folderManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), - ); - if (!folderManager || !folderManager.activePullRequest) { - return; - } - - const { indexChanges, workingTreeChanges } = folderManager.repository.state; - - if (!indexChanges.length) { - if (workingTreeChanges.length) { - const yes = vscode.l10n.t('Yes'); - const stageAll = await vscode.window.showWarningMessage( - vscode.l10n.t('There are no staged changes to suggest.\n\nWould you like to automatically stage all your of changes and suggest them?'), - { modal: true }, - yes, - ); - if (stageAll === yes) { - await vscode.commands.executeCommand('git.stageAll'); - } else { - return; - } - } else { - vscode.window.showInformationMessage(vscode.l10n.t('There are no changes to suggest.')); - return; - } - } - - const diff = await folderManager.repository.diff(true); - - let suggestEditMessage = vscode.l10n.t('Suggested edit:\n'); - if (e && e.inputBox && e.inputBox.value) { - suggestEditMessage = `${e.inputBox.value}\n`; - e.inputBox.value = ''; - } - - const suggestEditText = `${suggestEditMessage}\`\`\`diff\n${diff}\n\`\`\``; - await folderManager.activePullRequest.createIssueComment(suggestEditText); - - // Reset HEAD and then apply reverse diff - await vscode.commands.executeCommand('git.unstageAll'); - - const tempFilePath = pathLib.join( - folderManager.repository.rootUri.fsPath, - '.git', - `${folderManager.activePullRequest.number}.diff`, - ); - const encoder = new TextEncoder(); - const tempUri = vscode.Uri.file(tempFilePath); - - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(diff)); - await folderManager.repository.apply(tempFilePath, true); - await vscode.workspace.fs.delete(tempUri); - } catch (err) { - const moreError = `${err}${err.stderr ? `\n${err.stderr}` : ''}`; - Logger.error(`Applying patch failed: ${moreError}`); - vscode.window.showErrorMessage(vscode.l10n.t('Applying patch failed: {0}', formatError(err))); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => { - if (e instanceof RemoteFileChangeNode) { - const choice = await vscode.window.showInformationMessage( - vscode.l10n.t('{0} can\'t be opened locally. Do you want to open it on GitHub?', e.changeModel.fileName), - vscode.l10n.t('Open'), - ); - if (!choice) { - return; - } - } - if (e.changeModel.blobUrl) { - return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.changeModel.blobUrl)); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.copyCommitHash', (e: CommitNode) => { - vscode.env.clipboard.writeText(e.sha); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.openOriginalFile', async (e: GitFileChangeNode) => { - // if this is an image, encode it as a base64 data URI - const folderManager = reposManager.getManagerForIssueModel(e.pullRequest); - if (folderManager) { - const imageDataURI = await asTempStorageURI(e.changeModel.parentFilePath, folderManager.repository); - vscode.commands.executeCommand('vscode.open', imageDataURI || e.changeModel.parentFilePath); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode | undefined) => { - let uri: vscode.Uri | undefined; - const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - - if (e) { - uri = e.changeModel.filePath; - } else { - if (tab?.input instanceof vscode.TabInputTextDiff) { - uri = tab.input.modified; - } - } - if (uri) { - vscode.commands.executeCommand('vscode.open', uri, tab?.group.viewColumn); - } - }), - ); - - async function openDiffView(fileChangeNode: GitFileChangeNode | InMemFileChangeNode | vscode.Uri | undefined) { - if (fileChangeNode && !(fileChangeNode instanceof vscode.Uri)) { - const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); - if (!folderManager) { - return; - } - return fileChangeNode.openDiff(folderManager); - } else if (fileChangeNode || vscode.window.activeTextEditor) { - const editor = fileChangeNode instanceof vscode.Uri ? vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === fileChangeNode.toString())! : vscode.window.activeTextEditor!; - const visibleRanges = editor.visibleRanges; - const folderManager = reposManager.getManagerForFile(editor.document.uri); - if (!folderManager?.activePullRequest) { - return; - } - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); - if (!reviewManager) { - return; - } - const change = reviewManager.reviewModel.localFileChanges.find(change => change.resourceUri.with({ query: '' }).toString() === editor.document.uri.toString()); - await change?.openDiff(folderManager); - const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - const diffEditor = (tabInput instanceof vscode.TabInputTextDiff && tabInput.modified.toString() === editor.document.uri.toString()) ? vscode.window.activeTextEditor : undefined; - if (diffEditor) { - diffEditor.revealRange(visibleRanges[0]); - } - } - } - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openDiffView', - (fileChangeNode: GitFileChangeNode | InMemFileChangeNode | undefined) => { - return openDiffView(fileChangeNode); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openDiffViewFromEditor', - (uri: vscode.Uri) => { - return openDiffView(uri); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.deleteLocalBranch', async (e: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(e.pullRequestModel); - if (!folderManager) { - return; - } - const pullRequestModel = ensurePR(folderManager, e); - const DELETE_BRANCH_FORCE = 'Delete Unmerged Branch'; - let error = null; - - try { - await folderManager.deleteLocalPullRequest(pullRequestModel); - } catch (e) { - if (e.gitErrorCode === GitErrorCodes.BranchNotFullyMerged) { - const action = await vscode.window.showErrorMessage( - vscode.l10n.t('The local branch \'{0}\' is not fully merged. Are you sure you want to delete it?', pullRequestModel.localBranchName ?? 'unknown branch'), - DELETE_BRANCH_FORCE, - ); - - if (action !== DELETE_BRANCH_FORCE) { - return; - } - - try { - await folderManager.deleteLocalPullRequest(pullRequestModel, true); - } catch (e) { - error = e; - } - } else { - error = e; - } - } - - if (error) { - /* __GDPR__ - "pr.deleteLocalPullRequest.failure" : {} - */ - telemetry.sendTelemetryErrorEvent('pr.deleteLocalPullRequest.failure'); - await vscode.window.showErrorMessage(`Deleting local pull request branch failed: ${error}`); - } else { - /* __GDPR__ - "pr.deleteLocalPullRequest.success" : {} - */ - telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); - // fire and forget - vscode.commands.executeCommand('pr.refreshList'); - } - }), - ); - - function chooseReviewManager(repoPath?: string) { - if (repoPath) { - const uri = vscode.Uri.file(repoPath).toString(); - for (const mgr of reviewsManager.reviewManagers) { - if (mgr.repository.rootUri.toString() === uri) { - return mgr; - } - } - } - return chooseItem( - reviewsManager.reviewManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), - { placeHolder: vscode.l10n.t('Choose a repository to create a pull request in'), ignoreFocusOut: true }, - ); - } - - function isSourceControl(x: any): x is Repository { - return !!x?.rootUri; - } - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.create', - async (args?: { repoPath: string; compareBranch: string } | Repository) => { - // The arguments this is called with are either from the SCM view, or manually passed. - if (isSourceControl(args)) { - (await chooseReviewManager(args.rootUri.fsPath))?.createPullRequest(); - } else { - (await chooseReviewManager(args?.repoPath))?.createPullRequest(args?.compareBranch); - } - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.pushAndCreate', - async (args?: any | Repository) => { - if (isSourceControl(args)) { - const reviewManager = await chooseReviewManager(args.rootUri.fsPath); - const folderManager = reposManager.getManagerForFile(args.rootUri); - let create = true; - if (folderManager?.activePullRequest) { - const push = vscode.l10n.t('Push'); - const result = await vscode.window.showInformationMessage(vscode.l10n.t('You already have a pull request for this branch. Do you want to push your changes to the remote branch?'), { modal: true }, push); - if (result !== push) { - return; - } - create = false; - } - if (reviewManager) { - if (args.state.HEAD?.upstream) { - await args.push(); - } - if (create) { - reviewManager.createPullRequest(); - } - } - } - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.pick', async (pr: PRNode | DescriptionNode | PullRequestModel) => { - if (pr === undefined) { - // This is unexpected, but has happened a few times. - Logger.error('Unexpectedly received undefined when picking a PR.'); - return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); - } - - let pullRequestModel: PullRequestModel; - let repository: Repository | undefined; - - if (pr instanceof PRNode || pr instanceof DescriptionNode) { - pullRequestModel = pr.pullRequestModel; - repository = pr.repository; - } else { - pullRequestModel = pr; - } - - const fromDescriptionPage = pr instanceof PullRequestModel; - /* __GDPR__ - "pr.checkout" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: fromDescriptionPage.toString() }); - - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.SourceControl, - title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), - }, - async () => { - await ReviewManager.getReviewManagerForRepository( - reviewsManager.reviewManagers, - pullRequestModel.githubRepository, - repository - )?.switch(pullRequestModel); - }, - ); - }), - ); - context.subscriptions.push( - vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | DescriptionNode | PullRequestModel) => { - if (pr === undefined) { - // This is unexpected, but has happened a few times. - Logger.error('Unexpectedly received undefined when picking a PR.'); - return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); - } - - let pullRequestModel: PullRequestModel; - - if (pr instanceof PRNode || pr instanceof DescriptionNode) { - pullRequestModel = pr.pullRequestModel; - } else { - pullRequestModel = pr; - } - - const folderReposManager = reposManager.getManagerForIssueModel(pullRequestModel); - if (!folderReposManager) { - return; - } - return PullRequestModel.openChanges(folderReposManager, pullRequestModel); - }), - ); - - let isCheckingOutFromReadonlyFile = false; - context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromReadonlyFile', async () => { - const uri = vscode.window.activeTextEditor?.document.uri; - if (uri?.scheme !== Schemes.Pr) { - return; - } - const prUriPropserties = fromPRUri(uri); - if (prUriPropserties === undefined) { - return; - } - let githubRepository: GitHubRepository | undefined; - const folderManager = reposManager.folderManagers.find(folderManager => { - githubRepository = folderManager.gitHubRepositories.find(githubRepo => githubRepo.remote.remoteName === prUriPropserties.remoteName); - return !!githubRepository; - }); - if (!folderManager || !githubRepository) { - return; - } - const prModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, () => folderManager.fetchById(githubRepository!, Number(prUriPropserties.prNumber))); - if (prModel && !isCheckingOutFromReadonlyFile) { - isCheckingOutFromReadonlyFile = true; - try { - await ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.switch(prModel); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to check out pull request from read-only file: {0}', e instanceof Error ? e.message : 'unknown')); - } - isCheckingOutFromReadonlyFile = false; - } - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | DescriptionNode | PullRequestModel) => { - if (pr === undefined) { - // This is unexpected, but has happened a few times. - Logger.error('Unexpectedly received undefined when picking a PR.'); - return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); - } - - let pullRequestModel: PullRequestModel; - - if (pr instanceof PRNode || pr instanceof DescriptionNode) { - pullRequestModel = pr.pullRequestModel; - } else { - pullRequestModel = pr; - } - - return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(pullRequestModel))); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.exit', async (pr: PRNode | DescriptionNode | PullRequestModel | undefined) => { - let pullRequestModel: PullRequestModel | undefined; - - if (pr instanceof PRNode || pr instanceof DescriptionNode) { - pullRequestModel = pr.pullRequestModel; - } else if (pr === undefined) { - pullRequestModel = await chooseItem(reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR), - itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: vscode.l10n.t('Choose the pull request to exit') }); - } else { - pullRequestModel = pr; - } - - if (!pullRequestModel) { - return; - } - - const fromDescriptionPage = pr instanceof PullRequestModel; - /* __GDPR__ - "pr.exit" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - telemetry.sendTelemetryEvent('pr.exit', { fromDescription: fromDescriptionPage.toString() }); - - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.SourceControl, - title: vscode.l10n.t('Exiting Pull Request'), - }, - async () => { - const branch = await pullRequestModel!.githubRepository.getDefaultBranch(); - const manager = reposManager.getManagerForIssueModel(pullRequestModel); - if (manager) { - const prBranch = manager.repository.state.HEAD?.name; - await manager.checkoutDefaultBranch(branch); - if (prBranch) { - await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); - } - } - }, - ); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.merge', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; - } - const pullRequest = ensurePR(folderManager, pr); - // TODO check is codespaces - - const isCrossRepository = - pullRequest.base && - pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - - const showMergeOnGitHub = isCrossRepository && isInCodespaces(); - if (showMergeOnGitHub) { - return openPullRequestOnGitHub(pullRequest, telemetry); - } - - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to merge this pull request on GitHub?'), - { modal: true }, - yes, - ) - .then(async value => { - let newPR; - if (value === yes) { - try { - newPR = await folderManager.mergePullRequest(pullRequest); - return newPR; - } catch (e) { - vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); - return newPR; - } - } - }); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.readyForReview', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; - } - const pullRequest = ensurePR(folderManager, pr); - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to mark this pull request as ready to review on GitHub?'), - { modal: true }, - yes, - ) - .then(async value => { - let isDraft; - if (value === yes) { - try { - isDraft = await pullRequest.setReadyForReview(); - vscode.commands.executeCommand('pr.refreshList'); - return isDraft; - } catch (e) { - vscode.window.showErrorMessage( - `Unable to mark pull request as ready to review. ${formatError(e)}`, - ); - return isDraft; - } - } - }); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.close', async (pr?: PRNode | PullRequestModel, message?: string) => { - let pullRequestModel: PullRequestModel | undefined; - if (pr) { - pullRequestModel = pr instanceof PullRequestModel ? pr : pr.pullRequestModel; - } else { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR); - pullRequestModel = await chooseItem( - activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: vscode.l10n.t('Pull request to close') }, - ); - } - if (!pullRequestModel) { - return; - } - const pullRequest: PullRequestModel = pullRequestModel; - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.'), - { modal: true }, - yes, - vscode.l10n.t('No'), - ) - .then(async value => { - if (value === yes) { - try { - let newComment: IComment | undefined = undefined; - if (message) { - newComment = await pullRequest.createIssueComment(message); - } - - const newPR = await pullRequest.close(); - vscode.commands.executeCommand('pr.refreshList'); - _onDidUpdatePR.fire(newPR); - return newComment; - } catch (e) { - vscode.window.showErrorMessage(`Unable to close pull request. ${formatError(e)}`); - _onDidUpdatePR.fire(); - } - } - - _onDidUpdatePR.fire(); - }); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.dismissNotification', node => { - if (node instanceof PRNode) { - tree.notificationProvider.markPrNotificationsAsRead(node.pullRequestModel).then( - () => tree.refresh(node) - ); - - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openDescription', - async (argument: DescriptionNode | PullRequestModel | undefined) => { - let pullRequestModel: PullRequestModel | undefined; - if (!argument) { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(manager => manager.activePullRequest!) - .filter(activePR => !!activePR); - if (activePullRequests.length >= 1) { - pullRequestModel = await chooseItem( - activePullRequests, - itemValue => itemValue.title, - ); - } - } else { - pullRequestModel = argument instanceof DescriptionNode ? argument.pullRequestModel : argument; - } - - if (!pullRequestModel) { - Logger.appendLine('No pull request found.'); - return; - } - - const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); - if (!folderManager) { - return; - } - - let descriptionNode: DescriptionNode | undefined; - if (argument instanceof DescriptionNode) { - descriptionNode = argument; - } else { - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); - if (!reviewManager) { - return; - } - - descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); - } - - await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager, !(argument instanceof DescriptionNode), !(argument instanceof RepositoryChangesNode), tree.notificationProvider); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.refreshDescription', async () => { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.refresh(); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: DescriptionNode) => { - const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); - if (!folderManager) { - return; - } - const pr = descriptionNode.pullRequestModel; - const pullRequest = ensurePR(folderManager, pr); - descriptionNode.reveal(descriptionNode, { select: true, focus: true }); - // Create and show a new webview - PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, true); - - /* __GDPR__ - "pr.openDescriptionToTheSide" : {} - */ - telemetry.sendTelemetryEvent('pr.openDescriptionToTheSide'); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: DescriptionNode) => { - descriptionNode.pullRequestModel.showChangesSinceReview = true; - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: DescriptionNode) => { - descriptionNode.pullRequestModel.showChangesSinceReview = false; - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.signin', async () => { - await reposManager.authenticate(); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.signinNoEnterprise', async () => { - await reposManager.authenticate(false); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.signinenterprise', async () => { - await reposManager.authenticate(true); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.deleteLocalBranchesNRemotes', async () => { - for (const folderManager of reposManager.folderManagers) { - await folderManager.deleteLocalBranchesNRemotes(); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.signinAndRefreshList', async () => { - if (await reposManager.authenticate()) { - vscode.commands.executeCommand('pr.refreshList'); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.configureRemotes', async () => { - return vscode.commands.executeCommand('workbench.action.openSettings', `@ext:${EXTENSION_ID} remotes`); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.startReview', async (reply: CommentReply) => { - /* __GDPR__ - "pr.startReview" : {} - */ - telemetry.sendTelemetryEvent('pr.startReview'); - const handler = resolveCommentHandler(reply.thread); - - if (handler) { - handler.startReview(reply.thread, reply.text); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.openReview', async (thread: GHPRCommentThread) => { - /* __GDPR__ - "pr.openReview" : {} - */ - telemetry.sendTelemetryEvent('pr.openReview'); - const handler = resolveCommentHandler(thread); - - if (handler) { - await handler.openReview(thread); - } - }), - ); - - function threadAndText(commentLike: CommentReply | GHPRCommentThread | GHPRComment | any): { thread: GHPRCommentThread, text: string } { - let thread: GHPRCommentThread; - let text: string = ''; - if (commentLike instanceof GHPRComment) { - thread = commentLike.parent; - } else if (CommentReply.is(commentLike)) { - thread = commentLike.thread; - } else if (GHPRCommentThread.is(commentLike?.thread)) { - thread = commentLike.thread; - } else { - thread = commentLike; - } - return { thread, text }; - } - - context.subscriptions.push( - vscode.commands.registerCommand('pr.resolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { - /* __GDPR__ - "pr.resolveReviewThread" : {} - */ - telemetry.sendTelemetryEvent('pr.resolveReviewThread'); - const { thread, text } = threadAndText(commentLike); - const handler = resolveCommentHandler(thread); - - if (handler) { - await handler.resolveReviewThread(thread, text); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.unresolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { - /* __GDPR__ - "pr.unresolveReviewThread" : {} - */ - telemetry.sendTelemetryEvent('pr.unresolveReviewThread'); - const { thread, text } = threadAndText(commentLike); - - const handler = resolveCommentHandler(thread); - - if (handler) { - await handler.unresolveReviewThread(thread, text); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.createComment', async (reply: CommentReply) => { - /* __GDPR__ - "pr.createComment" : {} - */ - telemetry.sendTelemetryEvent('pr.createComment'); - const handler = resolveCommentHandler(reply.thread); - - if (handler) { - handler.createOrReplyComment(reply.thread, reply.text, false); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.createSingleComment', async (reply: CommentReply) => { - /* __GDPR__ - "pr.createSingleComment" : {} - */ - telemetry.sendTelemetryEvent('pr.createSingleComment'); - const handler = resolveCommentHandler(reply.thread); - - if (handler) { - handler.createOrReplyComment(reply.thread, reply.text, true); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.makeSuggestion', async (reply: CommentReply | GHPRComment) => { - const thread = reply instanceof GHPRComment ? reply.parent : reply.thread; - if (!thread.range) { - return; - } - const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor - : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); - if (!commentEditor) { - Logger.error('No comment editor visible for making a suggestion.'); - vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to make a suggestion in.')); - return; - } - const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === thread.uri.toString()); - const contents = editor?.document.getText(new vscode.Range(thread.range.start.line, 0, thread.range.end.line, editor.document.lineAt(thread.range.end.line).text.length)); - const position = commentEditor.document.lineAt(commentEditor.selection.end.line).range.end; - return commentEditor.edit((editBuilder) => { - editBuilder.insert(position, ` -\`\`\`suggestion -${contents} -\`\`\``); - }); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ - "pr.editComment" : {} - */ - telemetry.sendTelemetryEvent('pr.editComment'); - comment.startEdit(); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.editQuery', (query: CategoryTreeNode) => { - /* __GDPR__ - "pr.editQuery" : {} - */ - telemetry.sendTelemetryEvent('pr.editQuery'); - return query.editQuery(); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.cancelEditComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ - "pr.cancelEditComment" : {} - */ - telemetry.sendTelemetryEvent('pr.cancelEditComment'); - comment.cancelEdit(); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.saveComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ - "pr.saveComment" : {} - */ - telemetry.sendTelemetryEvent('pr.saveComment'); - const handler = resolveCommentHandler(comment.parent); - - if (handler) { - await handler.editComment(comment.parent, comment); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.deleteComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ - "pr.deleteComment" : {} - */ - telemetry.sendTelemetryEvent('pr.deleteComment'); - const deleteOption = vscode.l10n.t('Delete'); - const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Delete comment?'), { modal: true }, deleteOption); - - if (shouldDelete === deleteOption) { - const handler = resolveCommentHandler(comment.parent); - - if (handler) { - await handler.deleteComment(comment.parent, comment); - } - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('review.openFile', (value: GitFileChangeNode | vscode.Uri) => { - const command = value instanceof GitFileChangeNode ? value.openFileCommand() : openFileCommand(value); - vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('review.openLocalFile', (value: vscode.Uri) => { - const { path, rootPath } = fromReviewUri(value.query); - const localUri = vscode.Uri.joinPath(vscode.Uri.file(rootPath), path); - const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === value.toString()); - const command = openFileCommand(localUri, editor ? { selection: editor.selection } : undefined); - vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.refreshChanges', _ => { - reviewsManager.reviewManagers.forEach(reviewManager => { - reviewManager.updateComments(); - PullRequestOverviewPanel.refresh(); - reviewManager.changesInPrDataProvider.refresh(); - }); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.setFileListLayoutAsTree', _ => { - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'tree', true); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.setFileListLayoutAsFlat', _ => { - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'flat', true); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); - if (folderManager && prNode.pullRequestModel.equals(folderManager?.activePullRequest)) { - ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); - } - - PullRequestOverviewPanel.refresh(); - tree.refresh(prNode); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { - try { - if (treeNode === undefined) { - // Use the active editor to enable keybindings - treeNode = vscode.window.activeTextEditor?.document.uri; - } - - if (treeNode instanceof FileChangeNode) { - await treeNode.markFileAsViewed(false); - } else if (treeNode) { - // When the argument is a uri it came from the editor menu and we should also close the file - // Do the close first to improve perceived performance of marking as viewed. - const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - if (tab) { - let compareUri: vscode.Uri | undefined = undefined; - if (tab.input instanceof vscode.TabInputTextDiff) { - compareUri = tab.input.modified; - } else if (tab.input instanceof vscode.TabInputText) { - compareUri = tab.input.uri; - } - if (compareUri && treeNode.toString() === compareUri.toString()) { - vscode.window.tabGroups.close(tab); - } - } - const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.markFiles([treeNode.path], true, 'viewed'); - manager?.setFileViewedContext(); - } - } catch (e) { - vscode.window.showErrorMessage(`Marked file as viewed failed: ${e}`); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.unmarkFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { - try { - if (treeNode === undefined) { - // Use the active editor to enable keybindings - treeNode = vscode.window.activeTextEditor?.document.uri; - } - - if (treeNode instanceof FileChangeNode) { - treeNode.unmarkFileAsViewed(false); - } else if (treeNode) { - const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.markFiles([treeNode.path], true, 'unviewed'); - manager?.setFileViewedContext(); - } - } catch (e) { - vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.resetViewedFiles', async () => { - try { - return reposManager.folderManagers.map(async (manager) => { - await manager.activePullRequest?.unmarkAllFilesAsViewed(); - manager.setFileViewedContext(); - }); - } catch (e) { - vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.collapseAllComments', () => { - return vscode.commands.executeCommand('workbench.action.collapseAllComments'); - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.copyCommentLink', (comment) => { - if (comment instanceof GHPRComment) { - return vscode.env.clipboard.writeText(comment.rawComment.htmlUrl); - } - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async () => { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR); - const pr = await chooseItem( - activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: vscode.l10n.t('Pull request to create a link for') }, - ); - if (pr) { - return vscode.env.clipboard.writeText(vscodeDevPrLink(pr)); - } - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.checkoutByNumber', async () => { - - const githubRepositories: { manager: FolderRepositoryManager, repo: GitHubRepository }[] = []; - for (const manager of reposManager.folderManagers) { - const remotes = await manager.getActiveGitHubRemotes(await manager.getGitHubRemotes()); - const activeGitHubRepos = manager.gitHubRepositories.filter(repo => remotes.find(remote => remote.remoteName === repo.remote.remoteName)); - githubRepositories.push(...(activeGitHubRepos.map(repo => { return { manager, repo }; }))); - } - const githubRepo = await chooseItem<{ manager: FolderRepositoryManager, repo: GitHubRepository }>( - githubRepositories, - itemValue => `${itemValue.repo.remote.owner}/${itemValue.repo.remote.repositoryName}`, - { placeHolder: vscode.l10n.t('Which GitHub repository do you want to checkout the pull request from?') } - ); - if (!githubRepo) { - return; - } - const prNumberMatcher = /^#?(\d*)$/; - const prNumber = await vscode.window.showInputBox({ - ignoreFocusOut: true, prompt: vscode.l10n.t('Enter the pull request number'), - validateInput: (input: string) => { - const matches = input.match(prNumberMatcher); - if (!matches || (matches.length !== 2) || Number.isNaN(Number(matches[1]))) { - return vscode.l10n.t('Value must be a number'); - } - return undefined; - } - }); - if ((prNumber === undefined) || prNumber === '#') { - return; - } - const prModel = await githubRepo.manager.fetchById(githubRepo.repo, Number(prNumber.match(prNumberMatcher)![1])); - if (prModel) { - return ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, githubRepo.manager)?.switch(prModel); - } - })); - - function chooseRepoToOpen() { - const githubRepositories: GitHubRepository[] = []; - reposManager.folderManagers.forEach(manager => { - githubRepositories.push(...(manager.gitHubRepositories)); - }); - return chooseItem( - githubRepositories, - itemValue => `${itemValue.remote.owner}/${itemValue.remote.repositoryName}`, - { placeHolder: vscode.l10n.t('Which GitHub repository do you want to open?') } - ); - } - context.subscriptions.push( - vscode.commands.registerCommand('pr.openPullsWebsite', async () => { - const githubRepo = await chooseRepoToOpen(); - if (githubRepo) { - vscode.env.openExternal(getPullsUrl(githubRepo)); - } - })); - context.subscriptions.push( - vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { - const githubRepo = await chooseRepoToOpen(); - if (githubRepo) { - vscode.env.openExternal(getIssuesUrl(githubRepo)); - } - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.applySuggestion', async (comment: GHPRComment) => { - /* __GDPR__ - "pr.applySuggestion" : {} - */ - telemetry.sendTelemetryEvent('pr.applySuggestion'); - - const handler = resolveCommentHandler(comment.parent); - - if (handler instanceof ReviewCommentController) { - handler.applySuggestion(comment); - } - })); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.addFileComment', async () => { - return vscode.commands.executeCommand('workbench.action.addComment', { fileComment: true }); - })); - - context.subscriptions.push( - vscode.commands.registerCommand('review.diffWithPrHead', async (fileChangeNode: GitFileChangeNode) => { - const fileName = fileChangeNode.fileName; - let parentURI = toPRUri( - fileChangeNode.resourceUri, - fileChangeNode.pullRequest, - fileChangeNode.pullRequest.base.sha, - fileChangeNode.pullRequest.head.sha, - fileChangeNode.fileName, - true, - fileChangeNode.status); - let headURI = toPRUri( - fileChangeNode.resourceUri, - fileChangeNode.pullRequest, - fileChangeNode.pullRequest.base.sha, - fileChangeNode.pullRequest.head.sha, - fileChangeNode.fileName, - false, - fileChangeNode.status); - return vscode.commands.executeCommand('vscode.diff', parentURI, headURI, `${fileName} (Pull Request Compare Base with Head)`); - })); - - context.subscriptions.push( - vscode.commands.registerCommand('review.diffLocalWithPrHead', async (fileChangeNode: GitFileChangeNode) => { - const fileName = fileChangeNode.fileName; - let headURI = toPRUri( - fileChangeNode.resourceUri, - fileChangeNode.pullRequest, - fileChangeNode.pullRequest.base.sha, - fileChangeNode.pullRequest.head.sha, - fileChangeNode.fileName, - false, - fileChangeNode.status); - return vscode.commands.executeCommand('vscode.diff', headURI, fileChangeNode.resourceUri, `${fileName} (Pull Request Compare Head with Local)`); - })); - - async function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { - const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - const input = tab?.input; - if (!(input instanceof vscode.TabInputTextDiff)) { - return vscode.window.showErrorMessage(vscode.l10n.t('Current editor isn\'t a diff editor.')); - } - - const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === input.modified.toString()); - if (!editor) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unexpectedly unable to find the current modified editor.')); - } - - const editorUri = editor.document.uri; - if (input.original.scheme !== Schemes.Review) { - return vscode.window.showErrorMessage(vscode.l10n.t('Current file isn\'t a pull request diff.')); - } - - // Find the next diff in the current file to scroll to - const cursorPosition = editor.selection.active; - const iterateThroughDiffs = next ? diffs : diffs.reverse(); - for (const diff of iterateThroughDiffs) { - const practicalModifiedEndLineNumber = (diff.modifiedEndLineNumber > diff.modifiedStartLineNumber) ? diff.modifiedEndLineNumber : diff.modifiedStartLineNumber as number + 1; - const diffRange = new vscode.Range(diff.modifiedStartLineNumber ? diff.modifiedStartLineNumber - 1 : diff.modifiedStartLineNumber, 0, practicalModifiedEndLineNumber, 0); - - // cursorPosition.line is 0-based, diff.modifiedStartLineNumber is 1-based - if (next && cursorPosition.line + 1 < diff.modifiedStartLineNumber) { - editor.revealRange(diffRange); - editor.selection = new vscode.Selection(diffRange.start, diffRange.start); - return; - } else if (!next && cursorPosition.line + 1 > diff.modifiedStartLineNumber) { - editor.revealRange(diffRange); - editor.selection = new vscode.Selection(diffRange.start, diffRange.start); - return; - } - } - - // There is no new range to reveal, time to go to the next file. - const folderManager = reposManager.getManagerForFile(editorUri); - if (!folderManager) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find a repository for pull request.')); - } - - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); - if (!reviewManager) { - return vscode.window.showErrorMessage(vscode.l10n.t('Cannot find active pull request.')); - } - - if (!reviewManager.reviewModel.hasLocalFileChanges || (reviewManager.reviewModel.localFileChanges.length === 0)) { - return vscode.window.showWarningMessage(vscode.l10n.t('Pull request data is not yet complete, please try again in a moment.')); - } - - for (let i = 0; i < reviewManager.reviewModel.localFileChanges.length; i++) { - const index = next ? i : reviewManager.reviewModel.localFileChanges.length - 1 - i; - const localFileChange = reviewManager.reviewModel.localFileChanges[index]; - if (localFileChange.changeModel.filePath.toString() === editorUri.toString()) { - const nextIndex = next ? index + 1 : index - 1; - if (reviewManager.reviewModel.localFileChanges.length > nextIndex) { - await reviewManager.reviewModel.localFileChanges[nextIndex].openDiff(folderManager); - // if going backwards, we now need to go to the last diff in the file - if (!next) { - const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.filePath.toString()); - if (editor) { - const diffs = await reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.diffHunks(); - const diff = diffs[diffs.length - 1]; - const diffNewEndLine = diff.newLineNumber + diff.newLength; - const practicalModifiedEndLineNumber = (diffNewEndLine > diff.newLineNumber) ? diffNewEndLine : diff.newLineNumber as number + 1; - const diffRange = new vscode.Range(diff.newLineNumber ? diff.newLineNumber - 1 : diff.newLineNumber, 0, practicalModifiedEndLineNumber, 0); - editor.revealRange(diffRange); - editor.selection = new vscode.Selection(diffRange.start, diffRange.start); - } - } - return; - } - } - } - // No further files in PR. - const goInCircle = next ? vscode.l10n.t('Go to first diff') : vscode.l10n.t('Go to last diff'); - return vscode.window.showInformationMessage(vscode.l10n.t('There are no more diffs in this pull request.'), goInCircle).then(result => { - if (result === goInCircle) { - return reviewManager.reviewModel.localFileChanges[next ? 0 : reviewManager.reviewModel.localFileChanges.length - 1].openDiff(folderManager); - } - }); - } - - context.subscriptions.push( - vscode.commands.registerDiffInformationCommand('pr.goToNextDiffInPr', async (diffs: vscode.LineChange[]) => { - goToNextPrevDiff(diffs, true); - })); - context.subscriptions.push( - vscode.commands.registerDiffInformationCommand('pr.goToPreviousDiffInPr', async (diffs: vscode.LineChange[]) => { - goToNextPrevDiff(diffs, false); - })); - - context.subscriptions.push(vscode.commands.registerCommand('pr.refreshComments', async () => { - for (const folderManager of reposManager.folderManagers) { - for (const githubRepository of folderManager.gitHubRepositories) { - for (const pullRequest of githubRepository.pullRequestModels) { - if (pullRequest[1].isResolved() && pullRequest[1].reviewThreadsCacheReady) { - pullRequest[1].initializeReviewThreadCache(); - } - } - } - } - })); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import { Repository } from './api/api'; +import { GitErrorCodes } from './api/api1'; +import { CommentReply, resolveCommentHandler } from './commentHandlerResolver'; +import { IComment } from './common/comment'; +import Logger from './common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { ITelemetry } from './common/telemetry'; +import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './common/uri'; +import { formatError } from './common/utils'; +import { EXTENSION_ID } from './constants'; +import { FolderRepositoryManager } from './github/folderRepositoryManager'; +import { GitHubRepository } from './github/githubRepository'; +import { PullRequest } from './github/interface'; +import { NotificationProvider } from './github/notifications'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; +import { PullRequestModel } from './github/pullRequestModel'; +import { PullRequestOverviewPanel } from './github/pullRequestOverview'; +import { RepositoriesManager } from './github/repositoriesManager'; +import { getIssuesUrl, getPullsUrl, isInCodespaces, vscodeDevPrLink } from './github/utils'; +import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { ReviewCommentController } from './view/reviewCommentController'; +import { ReviewManager } from './view/reviewManager'; +import { ReviewsManager } from './view/reviewsManager'; +import { CategoryTreeNode } from './view/treeNodes/categoryNode'; +import { CommitNode } from './view/treeNodes/commitNode'; +import { DescriptionNode } from './view/treeNodes/descriptionNode'; +import { + FileChangeNode, + GitFileChangeNode, + InMemFileChangeNode, + openFileCommand, + RemoteFileChangeNode, +} from './view/treeNodes/fileChangeNode'; +import { PRNode } from './view/treeNodes/pullRequestNode'; +import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; + +const _onDidUpdatePR = new vscode.EventEmitter(); +export const onDidUpdatePR: vscode.Event = _onDidUpdatePR.event; + +function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | PullRequestModel): PullRequestModel { + // If the command is called from the command palette, no arguments are passed. + if (!pr) { + if (!folderRepoManager.activePullRequest) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to find current pull request.')); + throw new Error('Unable to find current pull request.'); + } + + return folderRepoManager.activePullRequest; + } else { + return pr instanceof PRNode ? pr.pullRequestModel : pr; + } +} + +export async function openDescription( + context: vscode.ExtensionContext, + telemetry: ITelemetry, + pullRequestModel: PullRequestModel, + descriptionNode: DescriptionNode | undefined, + folderManager: FolderRepositoryManager, + revealNode: boolean, + preserveFocus: boolean = true, + notificationProvider?: NotificationProvider +) { + const pullRequest = ensurePR(folderManager, pullRequestModel); + if (revealNode) { + descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); + } + // Create and show a new webview + await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); + + if (notificationProvider?.hasNotification(pullRequest)) { + notificationProvider.markPrNotificationsAsRead(pullRequest); + } + + /* __GDPR__ + "pr.openDescription" : {} + */ + telemetry.sendTelemetryEvent('pr.openDescription'); +} + +async function chooseItem( + activePullRequests: T[], + propertyGetter: (itemValue: T) => string, + options?: vscode.QuickPickOptions, +): Promise { + if (activePullRequests.length === 1) { + return activePullRequests[0]; + } + interface Item extends vscode.QuickPickItem { + itemValue: T; + } + const items: Item[] = activePullRequests.map(currentItem => { + return { + label: propertyGetter(currentItem), + itemValue: currentItem, + }; + }); + return (await vscode.window.showQuickPick(items, options))?.itemValue; +} + +export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | PullRequestModel, telemetry: ITelemetry) { + if (e instanceof PRNode || e instanceof DescriptionNode) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.pullRequestModel.html_url)); + } else { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.html_url)); + } + + /** __GDPR__ + "pr.openInGitHub" : {} + */ + telemetry.sendTelemetryEvent('pr.openInGitHub'); +} + +export function registerCommands( + context: vscode.ExtensionContext, + reposManager: RepositoriesManager, + reviewsManager: ReviewsManager, + telemetry: ITelemetry, + tree: PullRequestsTreeDataProvider +) { + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openPullRequestOnGitHub', + async (e: PRNode | DescriptionNode | PullRequestModel | undefined) => { + if (!e) { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + + if (activePullRequests.length >= 1) { + const result = await chooseItem( + activePullRequests, + itemValue => itemValue.html_url, + ); + if (result) { + openPullRequestOnGitHub(result, telemetry); + } + } + } else { + openPullRequestOnGitHub(e, telemetry); + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openAllDiffs', + async () => { + const activePullRequestsWithFolderManager = reposManager.folderManagers + .filter(folderManager => folderManager.activePullRequest) + .map(folderManager => { + return (({ activePr: folderManager.activePullRequest!, folderManager })); + }); + + const activePullRequestAndFolderManager = activePullRequestsWithFolderManager.length >= 1 + ? ( + await chooseItem( + activePullRequestsWithFolderManager, + itemValue => itemValue.activePr.html_url, + ) + ) + : activePullRequestsWithFolderManager[0]; + + if (!activePullRequestAndFolderManager) { + return; + } + + const { folderManager } = activePullRequestAndFolderManager; + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + + if (!reviewManager) { + return; + } + + reviewManager.reviewModel.localFileChanges + .forEach(localFileChange => localFileChange.openDiff(folderManager, { preview: false })); + } + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('review.suggestDiff', async e => { + const hasShownMessageKey = 'githubPullRequest.suggestDiffMessage'; + const hasShownMessage = context.globalState.get(hasShownMessageKey, false); + if (!hasShownMessage) { + await context.globalState.update(hasShownMessageKey, true); + const documentation = vscode.l10n.t('Open documentation'); + const result = await vscode.window.showInformationMessage(vscode.l10n.t('You can now make suggestions from review comments, just like on GitHub.com. See the documentation for more details.'), + { modal: true }, documentation); + if (result === documentation) { + return vscode.env.openExternal(vscode.Uri.parse('https://github.com/microsoft/vscode-pull-request-github/blob/main/documentation/suggestAChange.md')); + } + } + try { + const folderManager = await chooseItem( + reposManager.folderManagers, + itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), + ); + if (!folderManager || !folderManager.activePullRequest) { + return; + } + + const { indexChanges, workingTreeChanges } = folderManager.repository.state; + + if (!indexChanges.length) { + if (workingTreeChanges.length) { + const yes = vscode.l10n.t('Yes'); + const stageAll = await vscode.window.showWarningMessage( + vscode.l10n.t('There are no staged changes to suggest.\n\nWould you like to automatically stage all your of changes and suggest them?'), + { modal: true }, + yes, + ); + if (stageAll === yes) { + await vscode.commands.executeCommand('git.stageAll'); + } else { + return; + } + } else { + vscode.window.showInformationMessage(vscode.l10n.t('There are no changes to suggest.')); + return; + } + } + + const diff = await folderManager.repository.diff(true); + + let suggestEditMessage = vscode.l10n.t('Suggested edit:\n'); + if (e && e.inputBox && e.inputBox.value) { + suggestEditMessage = `${e.inputBox.value}\n`; + e.inputBox.value = ''; + } + + const suggestEditText = `${suggestEditMessage}\`\`\`diff\n${diff}\n\`\`\``; + await folderManager.activePullRequest.createIssueComment(suggestEditText); + + // Reset HEAD and then apply reverse diff + await vscode.commands.executeCommand('git.unstageAll'); + + const tempFilePath = pathLib.join( + folderManager.repository.rootUri.fsPath, + '.git', + `${folderManager.activePullRequest.number}.diff`, + ); + const encoder = new TextEncoder(); + const tempUri = vscode.Uri.file(tempFilePath); + + await vscode.workspace.fs.writeFile(tempUri, encoder.encode(diff)); + await folderManager.repository.apply(tempFilePath, true); + await vscode.workspace.fs.delete(tempUri); + } catch (err) { + const moreError = `${err}${err.stderr ? `\n${err.stderr}` : ''}`; + Logger.error(`Applying patch failed: ${moreError}`); + vscode.window.showErrorMessage(vscode.l10n.t('Applying patch failed: {0}', formatError(err))); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => { + if (e instanceof RemoteFileChangeNode) { + const choice = await vscode.window.showInformationMessage( + vscode.l10n.t('{0} can\'t be opened locally. Do you want to open it on GitHub?', e.changeModel.fileName), + vscode.l10n.t('Open'), + ); + if (!choice) { + return; + } + } + if (e.changeModel.blobUrl) { + return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.changeModel.blobUrl)); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyCommitHash', (e: CommitNode) => { + vscode.env.clipboard.writeText(e.sha); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openOriginalFile', async (e: GitFileChangeNode) => { + // if this is an image, encode it as a base64 data URI + const folderManager = reposManager.getManagerForIssueModel(e.pullRequest); + if (folderManager) { + const imageDataURI = await asTempStorageURI(e.changeModel.parentFilePath, folderManager.repository); + vscode.commands.executeCommand('vscode.open', imageDataURI || e.changeModel.parentFilePath); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode | undefined) => { + let uri: vscode.Uri | undefined; + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + + if (e) { + uri = e.changeModel.filePath; + } else { + if (tab?.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.modified; + } + } + if (uri) { + vscode.commands.executeCommand('vscode.open', uri, tab?.group.viewColumn); + } + }), + ); + + async function openDiffView(fileChangeNode: GitFileChangeNode | InMemFileChangeNode | vscode.Uri | undefined) { + if (fileChangeNode && !(fileChangeNode instanceof vscode.Uri)) { + const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); + if (!folderManager) { + return; + } + return fileChangeNode.openDiff(folderManager); + } else if (fileChangeNode || vscode.window.activeTextEditor) { + const editor = fileChangeNode instanceof vscode.Uri ? vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === fileChangeNode.toString())! : vscode.window.activeTextEditor!; + const visibleRanges = editor.visibleRanges; + const folderManager = reposManager.getManagerForFile(editor.document.uri); + if (!folderManager?.activePullRequest) { + return; + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return; + } + const change = reviewManager.reviewModel.localFileChanges.find(change => change.resourceUri.with({ query: '' }).toString() === editor.document.uri.toString()); + await change?.openDiff(folderManager); + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + const diffEditor = (tabInput instanceof vscode.TabInputTextDiff && tabInput.modified.toString() === editor.document.uri.toString()) ? vscode.window.activeTextEditor : undefined; + if (diffEditor) { + diffEditor.revealRange(visibleRanges[0]); + } + } + } + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffView', + (fileChangeNode: GitFileChangeNode | InMemFileChangeNode | undefined) => { + return openDiffView(fileChangeNode); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffViewFromEditor', + (uri: vscode.Uri) => { + return openDiffView(uri); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteLocalBranch', async (e: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(e.pullRequestModel); + if (!folderManager) { + return; + } + const pullRequestModel = ensurePR(folderManager, e); + const DELETE_BRANCH_FORCE = 'Delete Unmerged Branch'; + let error = null; + + try { + await folderManager.deleteLocalPullRequest(pullRequestModel); + } catch (e) { + if (e.gitErrorCode === GitErrorCodes.BranchNotFullyMerged) { + const action = await vscode.window.showErrorMessage( + vscode.l10n.t('The local branch \'{0}\' is not fully merged. Are you sure you want to delete it?', pullRequestModel.localBranchName ?? 'unknown branch'), + DELETE_BRANCH_FORCE, + ); + + if (action !== DELETE_BRANCH_FORCE) { + return; + } + + try { + await folderManager.deleteLocalPullRequest(pullRequestModel, true); + } catch (e) { + error = e; + } + } else { + error = e; + } + } + + if (error) { + /* __GDPR__ + "pr.deleteLocalPullRequest.failure" : {} + */ + telemetry.sendTelemetryErrorEvent('pr.deleteLocalPullRequest.failure'); + await vscode.window.showErrorMessage(`Deleting local pull request branch failed: ${error}`); + } else { + /* __GDPR__ + "pr.deleteLocalPullRequest.success" : {} + */ + telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); + // fire and forget + vscode.commands.executeCommand('pr.refreshList'); + } + }), + ); + + function chooseReviewManager(repoPath?: string) { + if (repoPath) { + const uri = vscode.Uri.file(repoPath).toString(); + for (const mgr of reviewsManager.reviewManagers) { + if (mgr.repository.rootUri.toString() === uri) { + return mgr; + } + } + } + return chooseItem( + reviewsManager.reviewManagers, + itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), + { placeHolder: vscode.l10n.t('Choose a repository to create a pull request in'), ignoreFocusOut: true }, + ); + } + + function isSourceControl(x: any): x is Repository { + return !!x?.rootUri; + } + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.create', + async (args?: { repoPath: string; compareBranch: string } | Repository) => { + // The arguments this is called with are either from the SCM view, or manually passed. + if (isSourceControl(args)) { + (await chooseReviewManager(args.rootUri.fsPath))?.createPullRequest(); + } else { + (await chooseReviewManager(args?.repoPath))?.createPullRequest(args?.compareBranch); + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.pushAndCreate', + async (args?: any | Repository) => { + if (isSourceControl(args)) { + const reviewManager = await chooseReviewManager(args.rootUri.fsPath); + const folderManager = reposManager.getManagerForFile(args.rootUri); + let create = true; + if (folderManager?.activePullRequest) { + const push = vscode.l10n.t('Push'); + const result = await vscode.window.showInformationMessage(vscode.l10n.t('You already have a pull request for this branch. Do you want to push your changes to the remote branch?'), { modal: true }, push); + if (result !== push) { + return; + } + create = false; + } + if (reviewManager) { + if (args.state.HEAD?.upstream) { + await args.push(); + } + if (create) { + reviewManager.createPullRequest(); + } + } + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pick', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; + + if (pr instanceof PRNode || pr instanceof DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + repository = pr.repository; + } else { + pullRequestModel = pr; + } + + const fromDescriptionPage = pr instanceof PullRequestModel; + /* __GDPR__ + "pr.checkout" : { + "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: fromDescriptionPage.toString() }); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), + }, + async () => { + await ReviewManager.getReviewManagerForRepository( + reviewsManager.reviewManagers, + pullRequestModel.githubRepository, + repository + )?.switch(pullRequestModel); + }, + ); + }), + ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + + if (pr instanceof PRNode || pr instanceof DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; + } + + const folderReposManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderReposManager) { + return; + } + return PullRequestModel.openChanges(folderReposManager, pullRequestModel); + }), + ); + + let isCheckingOutFromReadonlyFile = false; + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromReadonlyFile', async () => { + const uri = vscode.window.activeTextEditor?.document.uri; + if (uri?.scheme !== Schemes.Pr) { + return; + } + const prUriPropserties = fromPRUri(uri); + if (prUriPropserties === undefined) { + return; + } + let githubRepository: GitHubRepository | undefined; + const folderManager = reposManager.folderManagers.find(folderManager => { + githubRepository = folderManager.gitHubRepositories.find(githubRepo => githubRepo.remote.remoteName === prUriPropserties.remoteName); + return !!githubRepository; + }); + if (!folderManager || !githubRepository) { + return; + } + const prModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, () => folderManager.fetchById(githubRepository!, Number(prUriPropserties.prNumber))); + if (prModel && !isCheckingOutFromReadonlyFile) { + isCheckingOutFromReadonlyFile = true; + try { + await ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.switch(prModel); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to check out pull request from read-only file: {0}', e instanceof Error ? e.message : 'unknown')); + } + isCheckingOutFromReadonlyFile = false; + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + + if (pr instanceof PRNode || pr instanceof DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; + } + + return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(pullRequestModel))); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.exit', async (pr: PRNode | DescriptionNode | PullRequestModel | undefined) => { + let pullRequestModel: PullRequestModel | undefined; + + if (pr instanceof PRNode || pr instanceof DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + } else if (pr === undefined) { + pullRequestModel = await chooseItem(reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR), + itemValue => `${itemValue.number}: ${itemValue.title}`, + { placeHolder: vscode.l10n.t('Choose the pull request to exit') }); + } else { + pullRequestModel = pr; + } + + if (!pullRequestModel) { + return; + } + + const fromDescriptionPage = pr instanceof PullRequestModel; + /* __GDPR__ + "pr.exit" : { + "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + telemetry.sendTelemetryEvent('pr.exit', { fromDescription: fromDescriptionPage.toString() }); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: vscode.l10n.t('Exiting Pull Request'), + }, + async () => { + const branch = await pullRequestModel!.githubRepository.getDefaultBranch(); + const manager = reposManager.getManagerForIssueModel(pullRequestModel); + if (manager) { + const prBranch = manager.repository.state.HEAD?.name; + await manager.checkoutDefaultBranch(branch); + if (prBranch) { + await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); + } + } + }, + ); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.merge', async (pr?: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); + if (!folderManager) { + return; + } + const pullRequest = ensurePR(folderManager, pr); + // TODO check is codespaces + + const isCrossRepository = + pullRequest.base && + pullRequest.head && + !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + + const showMergeOnGitHub = isCrossRepository && isInCodespaces(); + if (showMergeOnGitHub) { + return openPullRequestOnGitHub(pullRequest, telemetry); + } + + const yes = vscode.l10n.t('Yes'); + return vscode.window + .showWarningMessage( + vscode.l10n.t('Are you sure you want to merge this pull request on GitHub?'), + { modal: true }, + yes, + ) + .then(async value => { + let newPR; + if (value === yes) { + try { + newPR = await folderManager.mergePullRequest(pullRequest); + return newPR; + } catch (e) { + vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); + return newPR; + } + } + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.readyForReview', async (pr?: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); + if (!folderManager) { + return; + } + const pullRequest = ensurePR(folderManager, pr); + const yes = vscode.l10n.t('Yes'); + return vscode.window + .showWarningMessage( + vscode.l10n.t('Are you sure you want to mark this pull request as ready to review on GitHub?'), + { modal: true }, + yes, + ) + .then(async value => { + let isDraft; + if (value === yes) { + try { + isDraft = await pullRequest.setReadyForReview(); + vscode.commands.executeCommand('pr.refreshList'); + return isDraft; + } catch (e) { + vscode.window.showErrorMessage( + `Unable to mark pull request as ready to review. ${formatError(e)}`, + ); + return isDraft; + } + } + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.close', async (pr?: PRNode | PullRequestModel, message?: string) => { + let pullRequestModel: PullRequestModel | undefined; + if (pr) { + pullRequestModel = pr instanceof PullRequestModel ? pr : pr.pullRequestModel; + } else { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + pullRequestModel = await chooseItem( + activePullRequests, + itemValue => `${itemValue.number}: ${itemValue.title}`, + { placeHolder: vscode.l10n.t('Pull request to close') }, + ); + } + if (!pullRequestModel) { + return; + } + const pullRequest: PullRequestModel = pullRequestModel; + const yes = vscode.l10n.t('Yes'); + return vscode.window + .showWarningMessage( + vscode.l10n.t('Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.'), + { modal: true }, + yes, + vscode.l10n.t('No'), + ) + .then(async value => { + if (value === yes) { + try { + let newComment: IComment | undefined = undefined; + if (message) { + newComment = await pullRequest.createIssueComment(message); + } + + const newPR = await pullRequest.close(); + vscode.commands.executeCommand('pr.refreshList'); + _onDidUpdatePR.fire(newPR); + return newComment; + } catch (e) { + vscode.window.showErrorMessage(`Unable to close pull request. ${formatError(e)}`); + _onDidUpdatePR.fire(); + } + } + + _onDidUpdatePR.fire(); + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.dismissNotification', node => { + if (node instanceof PRNode) { + tree.notificationProvider.markPrNotificationsAsRead(node.pullRequestModel).then( + () => tree.refresh(node) + ); + + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDescription', + async (argument: DescriptionNode | PullRequestModel | undefined) => { + let pullRequestModel: PullRequestModel | undefined; + if (!argument) { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(manager => manager.activePullRequest!) + .filter(activePR => !!activePR); + if (activePullRequests.length >= 1) { + pullRequestModel = await chooseItem( + activePullRequests, + itemValue => itemValue.title, + ); + } + } else { + pullRequestModel = argument instanceof DescriptionNode ? argument.pullRequestModel : argument; + } + + if (!pullRequestModel) { + Logger.appendLine('No pull request found.'); + return; + } + + const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderManager) { + return; + } + + let descriptionNode: DescriptionNode | undefined; + if (argument instanceof DescriptionNode) { + descriptionNode = argument; + } else { + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return; + } + + descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); + } + + await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager, !(argument instanceof DescriptionNode), !(argument instanceof RepositoryChangesNode), tree.notificationProvider); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.refreshDescription', async () => { + if (PullRequestOverviewPanel.currentPanel) { + PullRequestOverviewPanel.refresh(); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: DescriptionNode) => { + const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); + if (!folderManager) { + return; + } + const pr = descriptionNode.pullRequestModel; + const pullRequest = ensurePR(folderManager, pr); + descriptionNode.reveal(descriptionNode, { select: true, focus: true }); + // Create and show a new webview + PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, true); + + /* __GDPR__ + "pr.openDescriptionToTheSide" : {} + */ + telemetry.sendTelemetryEvent('pr.openDescriptionToTheSide'); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: DescriptionNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = true; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: DescriptionNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = false; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signin', async () => { + await reposManager.authenticate(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinNoEnterprise', async () => { + await reposManager.authenticate(false); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinenterprise', async () => { + await reposManager.authenticate(true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteLocalBranchesNRemotes', async () => { + for (const folderManager of reposManager.folderManagers) { + await folderManager.deleteLocalBranchesNRemotes(); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinAndRefreshList', async () => { + if (await reposManager.authenticate()) { + vscode.commands.executeCommand('pr.refreshList'); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.configureRemotes', async () => { + return vscode.commands.executeCommand('workbench.action.openSettings', `@ext:${EXTENSION_ID} remotes`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.startReview', async (reply: CommentReply) => { + /* __GDPR__ + "pr.startReview" : {} + */ + telemetry.sendTelemetryEvent('pr.startReview'); + const handler = resolveCommentHandler(reply.thread); + + if (handler) { + handler.startReview(reply.thread, reply.text); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openReview', async (thread: GHPRCommentThread) => { + /* __GDPR__ + "pr.openReview" : {} + */ + telemetry.sendTelemetryEvent('pr.openReview'); + const handler = resolveCommentHandler(thread); + + if (handler) { + await handler.openReview(thread); + } + }), + ); + + function threadAndText(commentLike: CommentReply | GHPRCommentThread | GHPRComment | any): { thread: GHPRCommentThread, text: string } { + let thread: GHPRCommentThread; + let text: string = ''; + if (commentLike instanceof GHPRComment) { + thread = commentLike.parent; + } else if (CommentReply.is(commentLike)) { + thread = commentLike.thread; + } else if (GHPRCommentThread.is(commentLike?.thread)) { + thread = commentLike.thread; + } else { + thread = commentLike; + } + return { thread, text }; + } + + context.subscriptions.push( + vscode.commands.registerCommand('pr.resolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { + /* __GDPR__ + "pr.resolveReviewThread" : {} + */ + telemetry.sendTelemetryEvent('pr.resolveReviewThread'); + const { thread, text } = threadAndText(commentLike); + const handler = resolveCommentHandler(thread); + + if (handler) { + await handler.resolveReviewThread(thread, text); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.unresolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { + /* __GDPR__ + "pr.unresolveReviewThread" : {} + */ + telemetry.sendTelemetryEvent('pr.unresolveReviewThread'); + const { thread, text } = threadAndText(commentLike); + + const handler = resolveCommentHandler(thread); + + if (handler) { + await handler.unresolveReviewThread(thread, text); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.createComment', async (reply: CommentReply) => { + /* __GDPR__ + "pr.createComment" : {} + */ + telemetry.sendTelemetryEvent('pr.createComment'); + const handler = resolveCommentHandler(reply.thread); + + if (handler) { + handler.createOrReplyComment(reply.thread, reply.text, false); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.createSingleComment', async (reply: CommentReply) => { + /* __GDPR__ + "pr.createSingleComment" : {} + */ + telemetry.sendTelemetryEvent('pr.createSingleComment'); + const handler = resolveCommentHandler(reply.thread); + + if (handler) { + handler.createOrReplyComment(reply.thread, reply.text, true); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.makeSuggestion', async (reply: CommentReply | GHPRComment) => { + const thread = reply instanceof GHPRComment ? reply.parent : reply.thread; + if (!thread.range) { + return; + } + const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor + : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); + if (!commentEditor) { + Logger.error('No comment editor visible for making a suggestion.'); + vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to make a suggestion in.')); + return; + } + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === thread.uri.toString()); + const contents = editor?.document.getText(new vscode.Range(thread.range.start.line, 0, thread.range.end.line, editor.document.lineAt(thread.range.end.line).text.length)); + const position = commentEditor.document.lineAt(commentEditor.selection.end.line).range.end; + return commentEditor.edit((editBuilder) => { + editBuilder.insert(position, ` +\`\`\`suggestion +${contents} +\`\`\``); + }); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ + "pr.editComment" : {} + */ + telemetry.sendTelemetryEvent('pr.editComment'); + comment.startEdit(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.editQuery', (query: CategoryTreeNode) => { + /* __GDPR__ + "pr.editQuery" : {} + */ + telemetry.sendTelemetryEvent('pr.editQuery'); + return query.editQuery(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.cancelEditComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ + "pr.cancelEditComment" : {} + */ + telemetry.sendTelemetryEvent('pr.cancelEditComment'); + comment.cancelEdit(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.saveComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ + "pr.saveComment" : {} + */ + telemetry.sendTelemetryEvent('pr.saveComment'); + const handler = resolveCommentHandler(comment.parent); + + if (handler) { + await handler.editComment(comment.parent, comment); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ + "pr.deleteComment" : {} + */ + telemetry.sendTelemetryEvent('pr.deleteComment'); + const deleteOption = vscode.l10n.t('Delete'); + const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Delete comment?'), { modal: true }, deleteOption); + + if (shouldDelete === deleteOption) { + const handler = resolveCommentHandler(comment.parent); + + if (handler) { + await handler.deleteComment(comment.parent, comment); + } + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('review.openFile', (value: GitFileChangeNode | vscode.Uri) => { + const command = value instanceof GitFileChangeNode ? value.openFileCommand() : openFileCommand(value); + vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('review.openLocalFile', (value: vscode.Uri) => { + const { path, rootPath } = fromReviewUri(value.query); + const localUri = vscode.Uri.joinPath(vscode.Uri.file(rootPath), path); + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === value.toString()); + const command = openFileCommand(localUri, editor ? { selection: editor.selection } : undefined); + vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.refreshChanges', _ => { + reviewsManager.reviewManagers.forEach(reviewManager => { + reviewManager.updateComments(); + PullRequestOverviewPanel.refresh(); + reviewManager.changesInPrDataProvider.refresh(); + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.setFileListLayoutAsTree', _ => { + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'tree', true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.setFileListLayoutAsFlat', _ => { + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'flat', true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); + if (folderManager && prNode.pullRequestModel.equals(folderManager?.activePullRequest)) { + ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); + } + + PullRequestOverviewPanel.refresh(); + tree.refresh(prNode); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { + try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + + if (treeNode instanceof FileChangeNode) { + await treeNode.markFileAsViewed(false); + } else if (treeNode) { + // When the argument is a uri it came from the editor menu and we should also close the file + // Do the close first to improve perceived performance of marking as viewed. + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + if (tab) { + let compareUri: vscode.Uri | undefined = undefined; + if (tab.input instanceof vscode.TabInputTextDiff) { + compareUri = tab.input.modified; + } else if (tab.input instanceof vscode.TabInputText) { + compareUri = tab.input.uri; + } + if (compareUri && treeNode.toString() === compareUri.toString()) { + vscode.window.tabGroups.close(tab); + } + } + const manager = reposManager.getManagerForFile(treeNode); + await manager?.activePullRequest?.markFiles([treeNode.path], true, 'viewed'); + manager?.setFileViewedContext(); + } + } catch (e) { + vscode.window.showErrorMessage(`Marked file as viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.unmarkFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { + try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + + if (treeNode instanceof FileChangeNode) { + treeNode.unmarkFileAsViewed(false); + } else if (treeNode) { + const manager = reposManager.getManagerForFile(treeNode); + await manager?.activePullRequest?.markFiles([treeNode.path], true, 'unviewed'); + manager?.setFileViewedContext(); + } + } catch (e) { + vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.resetViewedFiles', async () => { + try { + return reposManager.folderManagers.map(async (manager) => { + await manager.activePullRequest?.unmarkAllFilesAsViewed(); + manager.setFileViewedContext(); + }); + } catch (e) { + vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.collapseAllComments', () => { + return vscode.commands.executeCommand('workbench.action.collapseAllComments'); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyCommentLink', (comment) => { + if (comment instanceof GHPRComment) { + return vscode.env.clipboard.writeText(comment.rawComment.htmlUrl); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async () => { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + const pr = await chooseItem( + activePullRequests, + itemValue => `${itemValue.number}: ${itemValue.title}`, + { placeHolder: vscode.l10n.t('Pull request to create a link for') }, + ); + if (pr) { + return vscode.env.clipboard.writeText(vscodeDevPrLink(pr)); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.checkoutByNumber', async () => { + + const githubRepositories: { manager: FolderRepositoryManager, repo: GitHubRepository }[] = []; + for (const manager of reposManager.folderManagers) { + const remotes = await manager.getActiveGitHubRemotes(await manager.getGitHubRemotes()); + const activeGitHubRepos = manager.gitHubRepositories.filter(repo => remotes.find(remote => remote.remoteName === repo.remote.remoteName)); + githubRepositories.push(...(activeGitHubRepos.map(repo => { return { manager, repo }; }))); + } + const githubRepo = await chooseItem<{ manager: FolderRepositoryManager, repo: GitHubRepository }>( + githubRepositories, + itemValue => `${itemValue.repo.remote.owner}/${itemValue.repo.remote.repositoryName}`, + { placeHolder: vscode.l10n.t('Which GitHub repository do you want to checkout the pull request from?') } + ); + if (!githubRepo) { + return; + } + const prNumberMatcher = /^#?(\d*)$/; + const prNumber = await vscode.window.showInputBox({ + ignoreFocusOut: true, prompt: vscode.l10n.t('Enter the pull request number'), + validateInput: (input: string) => { + const matches = input.match(prNumberMatcher); + if (!matches || (matches.length !== 2) || Number.isNaN(Number(matches[1]))) { + return vscode.l10n.t('Value must be a number'); + } + return undefined; + } + }); + if ((prNumber === undefined) || prNumber === '#') { + return; + } + const prModel = await githubRepo.manager.fetchById(githubRepo.repo, Number(prNumber.match(prNumberMatcher)![1])); + if (prModel) { + return ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, githubRepo.manager)?.switch(prModel); + } + })); + + function chooseRepoToOpen() { + const githubRepositories: GitHubRepository[] = []; + reposManager.folderManagers.forEach(manager => { + githubRepositories.push(...(manager.gitHubRepositories)); + }); + return chooseItem( + githubRepositories, + itemValue => `${itemValue.remote.owner}/${itemValue.remote.repositoryName}`, + { placeHolder: vscode.l10n.t('Which GitHub repository do you want to open?') } + ); + } + context.subscriptions.push( + vscode.commands.registerCommand('pr.openPullsWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getPullsUrl(githubRepo)); + } + })); + context.subscriptions.push( + vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getIssuesUrl(githubRepo)); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.applySuggestion', async (comment: GHPRComment) => { + /* __GDPR__ + "pr.applySuggestion" : {} + */ + telemetry.sendTelemetryEvent('pr.applySuggestion'); + + const handler = resolveCommentHandler(comment.parent); + + if (handler instanceof ReviewCommentController) { + handler.applySuggestion(comment); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.addFileComment', async () => { + return vscode.commands.executeCommand('workbench.action.addComment', { fileComment: true }); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let parentURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + true, + fileChangeNode.status); + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', parentURI, headURI, `${fileName} (Pull Request Compare Base with Head)`); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffLocalWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', headURI, fileChangeNode.resourceUri, `${fileName} (Pull Request Compare Head with Local)`); + })); + + async function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + const input = tab?.input; + if (!(input instanceof vscode.TabInputTextDiff)) { + return vscode.window.showErrorMessage(vscode.l10n.t('Current editor isn\'t a diff editor.')); + } + + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === input.modified.toString()); + if (!editor) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unexpectedly unable to find the current modified editor.')); + } + + const editorUri = editor.document.uri; + if (input.original.scheme !== Schemes.Review) { + return vscode.window.showErrorMessage(vscode.l10n.t('Current file isn\'t a pull request diff.')); + } + + // Find the next diff in the current file to scroll to + const cursorPosition = editor.selection.active; + const iterateThroughDiffs = next ? diffs : diffs.reverse(); + for (const diff of iterateThroughDiffs) { + const practicalModifiedEndLineNumber = (diff.modifiedEndLineNumber > diff.modifiedStartLineNumber) ? diff.modifiedEndLineNumber : diff.modifiedStartLineNumber as number + 1; + const diffRange = new vscode.Range(diff.modifiedStartLineNumber ? diff.modifiedStartLineNumber - 1 : diff.modifiedStartLineNumber, 0, practicalModifiedEndLineNumber, 0); + + // cursorPosition.line is 0-based, diff.modifiedStartLineNumber is 1-based + if (next && cursorPosition.line + 1 < diff.modifiedStartLineNumber) { + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + return; + } else if (!next && cursorPosition.line + 1 > diff.modifiedStartLineNumber) { + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + return; + } + } + + // There is no new range to reveal, time to go to the next file. + const folderManager = reposManager.getManagerForFile(editorUri); + if (!folderManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find a repository for pull request.')); + } + + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Cannot find active pull request.')); + } + + if (!reviewManager.reviewModel.hasLocalFileChanges || (reviewManager.reviewModel.localFileChanges.length === 0)) { + return vscode.window.showWarningMessage(vscode.l10n.t('Pull request data is not yet complete, please try again in a moment.')); + } + + for (let i = 0; i < reviewManager.reviewModel.localFileChanges.length; i++) { + const index = next ? i : reviewManager.reviewModel.localFileChanges.length - 1 - i; + const localFileChange = reviewManager.reviewModel.localFileChanges[index]; + if (localFileChange.changeModel.filePath.toString() === editorUri.toString()) { + const nextIndex = next ? index + 1 : index - 1; + if (reviewManager.reviewModel.localFileChanges.length > nextIndex) { + await reviewManager.reviewModel.localFileChanges[nextIndex].openDiff(folderManager); + // if going backwards, we now need to go to the last diff in the file + if (!next) { + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.filePath.toString()); + if (editor) { + const diffs = await reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.diffHunks(); + const diff = diffs[diffs.length - 1]; + const diffNewEndLine = diff.newLineNumber + diff.newLength; + const practicalModifiedEndLineNumber = (diffNewEndLine > diff.newLineNumber) ? diffNewEndLine : diff.newLineNumber as number + 1; + const diffRange = new vscode.Range(diff.newLineNumber ? diff.newLineNumber - 1 : diff.newLineNumber, 0, practicalModifiedEndLineNumber, 0); + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + } + } + return; + } + } + } + // No further files in PR. + const goInCircle = next ? vscode.l10n.t('Go to first diff') : vscode.l10n.t('Go to last diff'); + return vscode.window.showInformationMessage(vscode.l10n.t('There are no more diffs in this pull request.'), goInCircle).then(result => { + if (result === goInCircle) { + return reviewManager.reviewModel.localFileChanges[next ? 0 : reviewManager.reviewModel.localFileChanges.length - 1].openDiff(folderManager); + } + }); + } + + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToNextDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, true); + })); + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToPreviousDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, false); + })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.refreshComments', async () => { + for (const folderManager of reposManager.folderManagers) { + for (const githubRepository of folderManager.gitHubRepositories) { + for (const pullRequest of githubRepository.pullRequestModels) { + if (pullRequest[1].isResolved() && pullRequest[1].reviewThreadsCacheReady) { + pullRequest[1].initializeReviewThreadCache(); + } + } + } + } + })); +} diff --git a/src/commentHandlerResolver.ts b/src/commentHandlerResolver.ts index 4018ca8be2..f6d5f17ba7 100644 --- a/src/commentHandlerResolver.ts +++ b/src/commentHandlerResolver.ts @@ -1,57 +1,58 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import Logger from './common/logger'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; - -export interface CommentHandler { - commentController?: vscode.CommentController; - hasCommentThread(thread: GHPRCommentThread): boolean; - - createOrReplyComment(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise; - editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; - deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; - - startReview(thread: GHPRCommentThread, input: string): Promise; - openReview(thread: GHPRCommentThread): Promise; - - resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; - unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; -} - -export interface CommentReply { - thread: GHPRCommentThread; - text: string; -} - -export namespace CommentReply { - export function is(commentReply: any): commentReply is CommentReply { - return commentReply && commentReply.thread && (commentReply.text !== undefined); - } -} - -const commentHandlers = new Map(); - -export function registerCommentHandler(key: string, commentHandler: CommentHandler) { - commentHandlers.set(key, commentHandler); -} - -export function unregisterCommentHandler(key: string) { - commentHandlers.delete(key); -} - -export function resolveCommentHandler(commentThread: GHPRCommentThread): CommentHandler | undefined { - for (const commentHandler of commentHandlers.values()) { - if (commentHandler.hasCommentThread(commentThread)) { - return commentHandler; - } - } - - Logger.warn(`Unable to find handler for comment thread ${commentThread.gitHubThreadId}`); - - return; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import Logger from './common/logger'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; + +export interface CommentHandler { + commentController?: vscode.CommentController; + hasCommentThread(thread: GHPRCommentThread): boolean; + + createOrReplyComment(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise; + editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; + deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; + + startReview(thread: GHPRCommentThread, input: string): Promise; + openReview(thread: GHPRCommentThread): Promise; + + resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; + unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; +} + +export interface CommentReply { + thread: GHPRCommentThread; + text: string; +} + +export namespace CommentReply { + export function is(commentReply: any): commentReply is CommentReply { + return commentReply && commentReply.thread && (commentReply.text !== undefined); + } +} + +const commentHandlers = new Map(); + +export function registerCommentHandler(key: string, commentHandler: CommentHandler) { + commentHandlers.set(key, commentHandler); +} + +export function unregisterCommentHandler(key: string) { + commentHandlers.delete(key); +} + +export function resolveCommentHandler(commentThread: GHPRCommentThread): CommentHandler | undefined { + for (const commentHandler of commentHandlers.values()) { + if (commentHandler.hasCommentThread(commentThread)) { + return commentHandler; + } + } + + Logger.warn(`Unable to find handler for comment thread ${commentThread.gitHubThreadId}`); + + return; +} diff --git a/src/common/async.ts b/src/common/async.ts index 35488704b6..9696c73f42 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -1,48 +1,50 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -function done(promise: Promise): Promise { - return promise.then(() => undefined); -} - -export function throttle(fn: () => Promise): () => Promise { - let current: Promise | undefined; - let next: Promise | undefined; - - const trigger = (): Promise => { - if (next) { - return next; - } - - if (current) { - next = done(current).then(() => { - next = undefined; - return trigger(); - }); - - return next; - } - - current = fn(); - - const clear = () => (current = undefined); - done(current).then(clear, clear); - - return current; - }; - - return trigger; -} - -export function debounce(fn: () => any, delay: number): () => void { - let timer: NodeJS.Timeout | undefined; - - return () => { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(() => fn(), delay); - }; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +function done(promise: Promise): Promise { + return promise.then(() => undefined); +} + + +export function throttle(fn: () => Promise): () => Promise { + let current: Promise | undefined; + let next: Promise | undefined; + + const trigger = (): Promise => { + if (next) { + return next; + } + + if (current) { + next = done(current).then(() => { + next = undefined; + return trigger(); + }); + + return next; + } + + current = fn(); + + const clear = () => (current = undefined); + done(current).then(clear, clear); + + return current; + }; + + return trigger; +} + +export function debounce(fn: () => any, delay: number): () => void { + let timer: NodeJS.Timeout | undefined; + + return () => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => fn(), delay); + }; +} diff --git a/src/common/authentication.ts b/src/common/authentication.ts index 3a761193b7..071920d5b4 100644 --- a/src/common/authentication.ts +++ b/src/common/authentication.ts @@ -1,27 +1,29 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export enum GitHubServerType { - None, - GitHubDotCom, - Enterprise -} - -export enum AuthProvider { - github = 'github', - githubEnterprise = 'github-enterprise' -} - -export class AuthenticationError extends Error { - name: string; - stack?: string; - constructor(public message: string) { - super(message); - } -} - -export function isSamlError(e: { message?: string }): boolean { - return !!e.message?.startsWith('Resource protected by organization SAML enforcement.'); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export enum GitHubServerType { + None, + GitHubDotCom, + Enterprise +} + + +export enum AuthProvider { + github = 'github', + githubEnterprise = 'github-enterprise' +} + +export class AuthenticationError extends Error { + name: string; + stack?: string; + constructor(public message: string) { + super(message); + } +} + +export function isSamlError(e: { message?: string }): boolean { + return !!e.message?.startsWith('Resource protected by organization SAML enforcement.'); +} diff --git a/src/common/comment.ts b/src/common/comment.ts index 4bdcfdc6a1..9596315124 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -1,74 +1,76 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IAccount } from '../github/interface'; -import { DiffHunk } from './diffHunk'; - -export enum DiffSide { - LEFT = 'LEFT', - RIGHT = 'RIGHT', -} - -export enum ViewedState { - DISMISSED = 'DISMISSED', - VIEWED = 'VIEWED', - UNVIEWED = 'UNVIEWED' -} - -export interface Reaction { - label: string; - count: number; - icon?: vscode.Uri; - viewerHasReacted: boolean; - reactors: readonly string[]; -} - -export enum SubjectType { - LINE = 'LINE', - FILE = 'FILE' -} - -export interface IReviewThread { - id: string; - prReviewDatabaseId?: number; - isResolved: boolean; - viewerCanResolve: boolean; - viewerCanUnresolve: boolean; - path: string; - diffSide: DiffSide; - startLine: number; - endLine: number; - originalStartLine: number; - originalEndLine: number; - isOutdated: boolean; - comments: IComment[]; - subjectType: SubjectType; -} - -export interface IComment { - bodyHTML?: string; - diffHunks?: DiffHunk[]; - canEdit?: boolean; - canDelete?: boolean; - url: string; - id: number; - pullRequestReviewId?: number; - diffHunk: string; - path?: string; - position?: number; - commitId?: string; - originalPosition?: number; - originalCommitId?: string; - user?: IAccount; - body: string; - createdAt: string; - htmlUrl: string; - isDraft?: boolean; - inReplyToId?: number; - graphNodeId: string; - reactions?: Reaction[]; - isResolved?: boolean; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { IAccount } from '../github/interface'; +import { DiffHunk } from './diffHunk'; + +export enum DiffSide { + LEFT = 'LEFT', + RIGHT = 'RIGHT', +} + + +export enum ViewedState { + DISMISSED = 'DISMISSED', + VIEWED = 'VIEWED', + UNVIEWED = 'UNVIEWED' +} + +export interface Reaction { + label: string; + count: number; + icon?: vscode.Uri; + viewerHasReacted: boolean; + reactors: readonly string[]; +} + +export enum SubjectType { + LINE = 'LINE', + FILE = 'FILE' +} + +export interface IReviewThread { + id: string; + prReviewDatabaseId?: number; + isResolved: boolean; + viewerCanResolve: boolean; + viewerCanUnresolve: boolean; + path: string; + diffSide: DiffSide; + startLine: number; + endLine: number; + originalStartLine: number; + originalEndLine: number; + isOutdated: boolean; + comments: IComment[]; + subjectType: SubjectType; +} + +export interface IComment { + bodyHTML?: string; + diffHunks?: DiffHunk[]; + canEdit?: boolean; + canDelete?: boolean; + url: string; + id: number; + pullRequestReviewId?: number; + diffHunk: string; + path?: string; + position?: number; + commitId?: string; + originalPosition?: number; + originalCommitId?: string; + user?: IAccount; + body: string; + createdAt: string; + htmlUrl: string; + isDraft?: boolean; + inReplyToId?: number; + graphNodeId: string; + reactions?: Reaction[]; + isResolved?: boolean; +} diff --git a/src/common/commentingRanges.ts b/src/common/commentingRanges.ts index f2083ccedf..1e2529019b 100644 --- a/src/common/commentingRanges.ts +++ b/src/common/commentingRanges.ts @@ -1,68 +1,70 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { DiffChangeType, DiffHunk } from './diffHunk'; -import { getZeroBased } from './diffPositionMapping'; -import Logger from './logger'; - -/** - * For the base file, the only commentable areas are deleted lines. For the modified file, - * comments can be added on any part of the diff hunk. - * @param diffHunks The diff hunks of the file - * @param isBase Whether the commenting ranges are calculated for the base or modified file - */ -export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean, logId: string = 'GetCommentingRanges'): vscode.Range[] { - if (diffHunks.length === 0) { - Logger.debug('No commenting ranges: File contains no diffs.', logId); - } - - const ranges: vscode.Range[] = []; - - for (let i = 0; i < diffHunks.length; i++) { - const diffHunk = diffHunks[i]; - let startingLine: number | undefined; - let length: number; - if (isBase) { - let endingLine: number | undefined; - for (let j = 0; j < diffHunk.diffLines.length; j++) { - const diffLine = diffHunk.diffLines[j]; - if (diffLine.type === DiffChangeType.Delete) { - if (startingLine !== undefined) { - endingLine = getZeroBased(diffLine.oldLineNumber); - } else { - startingLine = getZeroBased(diffLine.oldLineNumber); - endingLine = getZeroBased(diffLine.oldLineNumber); - } - } else { - if (startingLine !== undefined && endingLine !== undefined) { - ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); - startingLine = undefined; - endingLine = undefined; - } - } - } - - if (startingLine !== undefined && endingLine !== undefined) { - ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); - startingLine = undefined; - endingLine = undefined; - } else if (ranges.length === 0) { - Logger.debug('No commenting ranges: Diff is in base and none of the diff hunks could be added.', logId); - } - } else { - if (diffHunk.newLineNumber) { - startingLine = getZeroBased(diffHunk.newLineNumber); - length = getZeroBased(diffHunk.newLength); - ranges.push(new vscode.Range(startingLine, 0, startingLine + length, 0)); - } else { - Logger.debug('No commenting ranges: Diff is not base and newLineNumber is undefined.', logId); - } - } - } - - Logger.debug(`Found ${ranges.length} commenting ranges.`, logId); - return ranges; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { DiffChangeType, DiffHunk } from './diffHunk'; +import { getZeroBased } from './diffPositionMapping'; +import Logger from './logger'; + +/** + * For the base file, the on + * ly commentable areas are deleted lines. For the modified file, + * comments can be added on any part of the diff hunk. + * @param diffHunks The diff hunks of the file + * @param isBase Whether the commenting ranges are calculated for the base or modified file + */ +export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean, logId: string = 'GetCommentingRanges'): vscode.Range[] { + if (diffHunks.length === 0) { + Logger.debug('No commenting ranges: File contains no diffs.', logId); + } + + const ranges: vscode.Range[] = []; + + for (let i = 0; i < diffHunks.length; i++) { + const diffHunk = diffHunks[i]; + let startingLine: number | undefined; + let length: number; + if (isBase) { + let endingLine: number | undefined; + for (let j = 0; j < diffHunk.diffLines.length; j++) { + const diffLine = diffHunk.diffLines[j]; + if (diffLine.type === DiffChangeType.Delete) { + if (startingLine !== undefined) { + endingLine = getZeroBased(diffLine.oldLineNumber); + } else { + startingLine = getZeroBased(diffLine.oldLineNumber); + endingLine = getZeroBased(diffLine.oldLineNumber); + } + } else { + if (startingLine !== undefined && endingLine !== undefined) { + ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); + startingLine = undefined; + endingLine = undefined; + } + } + } + + if (startingLine !== undefined && endingLine !== undefined) { + ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); + startingLine = undefined; + endingLine = undefined; + } else if (ranges.length === 0) { + Logger.debug('No commenting ranges: Diff is in base and none of the diff hunks could be added.', logId); + } + } else { + if (diffHunk.newLineNumber) { + startingLine = getZeroBased(diffHunk.newLineNumber); + length = getZeroBased(diffHunk.newLength); + ranges.push(new vscode.Range(startingLine, 0, startingLine + length, 0)); + } else { + Logger.debug('No commenting ranges: Diff is not base and newLineNumber is undefined.', logId); + } + } + } + + Logger.debug(`Found ${ranges.length} commenting ranges.`, logId); + return ranges; +} diff --git a/src/common/diffHunk.ts b/src/common/diffHunk.ts index 6fa94e3dda..d87889a5b5 100644 --- a/src/common/diffHunk.ts +++ b/src/common/diffHunk.ts @@ -1,302 +1,304 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* - * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Models/DiffLine.cs - */ - -import { IRawFileChange } from '../github/interface'; -import { GitChangeType, InMemFileChange, SlimFileChange } from './file'; - -export enum DiffChangeType { - Context, - Add, - Delete, - Control, -} - -export class DiffLine { - public get raw(): string { - return this._raw; - } - - public get text(): string { - return this._raw.substr(1); - } - - constructor( - public type: DiffChangeType, - public oldLineNumber: number /* 1 based */, - public newLineNumber: number /* 1 based */, - public positionInHunk: number, - private _raw: string, - public endwithLineBreak: boolean = true, - ) { } -} - -export function getDiffChangeType(text: string) { - const c = text[0]; - switch (c) { - case ' ': - return DiffChangeType.Context; - case '+': - return DiffChangeType.Add; - case '-': - return DiffChangeType.Delete; - default: - return DiffChangeType.Control; - } -} - -export class DiffHunk { - public diffLines: DiffLine[] = []; - - constructor( - public oldLineNumber: number, - public oldLength: number, - public newLineNumber: number, - public newLength: number, - public positionInHunk: number, - ) { } -} - -export const DIFF_HUNK_HEADER = /^@@ \-(\d+)(,(\d+))?( \+(\d+)(,(\d+)?)?)? @@/; - -export function countCarriageReturns(text: string): number { - let count = 0; - let index = 0; - while ((index = text.indexOf('\r', index)) !== -1) { - index++; - count++; - } - - return count; -} - -export function* LineReader(text: string): IterableIterator { - let index = 0; - - while (index !== -1 && index < text.length) { - const startIndex = index; - index = text.indexOf('\n', index); - const endIndex = index !== -1 ? index : text.length; - let length = endIndex - startIndex; - - if (index !== -1) { - if (index > 0 && text[index - 1] === '\r') { - length--; - } - - index++; - } - - yield text.substr(startIndex, length); - } -} - -export function* parseDiffHunk(diffHunkPatch: string): IterableIterator { - const lineReader: Iterator = LineReader(diffHunkPatch); - - let itr = lineReader.next(); - let diffHunk: DiffHunk | undefined = undefined; - let positionInHunk = -1; - let oldLine = -1; - let newLine = -1; - - while (!itr.done) { - const line = itr.value; - if (DIFF_HUNK_HEADER.test(line)) { - if (diffHunk) { - yield diffHunk; - diffHunk = undefined; - } - - if (positionInHunk === -1) { - positionInHunk = 0; - } - - const matches = DIFF_HUNK_HEADER.exec(line); - const oriStartLine = (oldLine = Number(matches![1])); - // http://www.gnu.org/software/diffutils/manual/diffutils.html#Detailed-Unified - // `count` is added when the changes have more than 1 line. - const oriLen = Number(matches![3]) || 1; - const newStartLine = (newLine = Number(matches![5])); - const newLen = Number(matches![7]) || 1; - - diffHunk = new DiffHunk(oriStartLine, oriLen, newStartLine, newLen, positionInHunk); - // @rebornix todo, once we have enough tests, this should be removed. - diffHunk.diffLines.push(new DiffLine(DiffChangeType.Control, -1, -1, positionInHunk, line)); - } else if (diffHunk) { - const type = getDiffChangeType(line); - - if (type === DiffChangeType.Control) { - if (diffHunk.diffLines && diffHunk.diffLines.length) { - diffHunk.diffLines[diffHunk.diffLines.length - 1].endwithLineBreak = false; - } - } else { - diffHunk.diffLines.push( - new DiffLine( - type, - type !== DiffChangeType.Add ? oldLine : -1, - type !== DiffChangeType.Delete ? newLine : -1, - positionInHunk, - line, - ), - ); - - const lineCount = 1 + countCarriageReturns(line); - - switch (type) { - case DiffChangeType.Context: - oldLine += lineCount; - newLine += lineCount; - break; - case DiffChangeType.Delete: - oldLine += lineCount; - break; - case DiffChangeType.Add: - newLine += lineCount; - break; - } - } - } - - if (positionInHunk !== -1) { - ++positionInHunk; - } - itr = lineReader.next(); - } - - if (diffHunk) { - yield diffHunk; - } -} - -export function parsePatch(patch: string): DiffHunk[] { - const diffHunkReader = parseDiffHunk(patch); - let diffHunkIter = diffHunkReader.next(); - const diffHunks: DiffHunk[] = []; - - const right: string[] = []; - while (!diffHunkIter.done) { - const diffHunk = diffHunkIter.value; - diffHunks.push(diffHunk); - - for (let j = 0; j < diffHunk.diffLines.length; j++) { - const diffLine = diffHunk.diffLines[j]; - if (diffLine.type === DiffChangeType.Delete || diffLine.type === DiffChangeType.Control) { - } else if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else { - const codeInFirstLine = diffLine.text; - right.push(codeInFirstLine); - } - } - - diffHunkIter = diffHunkReader.next(); - } - - return diffHunks; -} - -export function getModifiedContentFromDiffHunk(originalContent: string, patch: string) { - const left = originalContent.split(/\r?\n/); - const diffHunkReader = parseDiffHunk(patch); - let diffHunkIter = diffHunkReader.next(); - const diffHunks: DiffHunk[] = []; - - const right: string[] = []; - let lastCommonLine = 0; - while (!diffHunkIter.done) { - const diffHunk: DiffHunk = diffHunkIter.value; - diffHunks.push(diffHunk); - - const oriStartLine = diffHunk.oldLineNumber; - - for (let j = lastCommonLine + 1; j < oriStartLine; j++) { - right.push(left[j - 1]); - } - - lastCommonLine = oriStartLine + diffHunk.oldLength - 1; - - for (let j = 0; j < diffHunk.diffLines.length; j++) { - const diffLine = diffHunk.diffLines[j]; - if (diffLine.type === DiffChangeType.Delete || diffLine.type === DiffChangeType.Control) { - } else if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else { - const codeInFirstLine = diffLine.text; - right.push(codeInFirstLine); - } - } - - diffHunkIter = diffHunkReader.next(); - } - - if (lastCommonLine < left.length) { - for (let j = lastCommonLine + 1; j <= left.length; j++) { - right.push(left[j - 1]); - } - } - - return right.join('\n'); -} - -export function getGitChangeType(status: string): GitChangeType { - switch (status) { - case 'removed': - return GitChangeType.DELETE; - case 'added': - return GitChangeType.ADD; - case 'renamed': - return GitChangeType.RENAME; - case 'modified': - return GitChangeType.MODIFY; - default: - return GitChangeType.UNKNOWN; - } -} - -export async function parseDiff( - reviews: IRawFileChange[], - parentCommit: string, -): Promise<(InMemFileChange | SlimFileChange)[]> { - const fileChanges: (InMemFileChange | SlimFileChange)[] = []; - - for (let i = 0; i < reviews.length; i++) { - const review = reviews[i]; - const gitChangeType = getGitChangeType(review.status); - - if (!review.patch && - // We don't need to make a SlimFileChange for empty file adds. - !((gitChangeType === GitChangeType.ADD) && (review.additions === 0))) { - fileChanges.push( - new SlimFileChange( - parentCommit, - review.blob_url, - gitChangeType, - review.filename, - review.previous_filename, - ), - ); - continue; - } - - const diffHunks = review.patch ? parsePatch(review.patch) : []; - fileChanges.push( - new InMemFileChange( - parentCommit, - gitChangeType, - review.filename, - review.previous_filename, - review.patch, - diffHunks, - review.blob_url, - ), - ); - } - - return fileChanges; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +/* + * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Models/DiffLine.cs + */ + +import { IRawFileChange } from '../github/interface'; +import { GitChangeType, InMemFileChange, SlimFileChange } from './file'; + + +export enum DiffChangeType { + Context, + Add, + Delete, + Control, +} + +export class DiffLine { + public get raw(): string { + return this._raw; + } + + public get text(): string { + return this._raw.substr(1); + } + + constructor( + public type: DiffChangeType, + public oldLineNumber: number /* 1 based */, + public newLineNumber: number /* 1 based */, + public positionInHunk: number, + private _raw: string, + public endwithLineBreak: boolean = true, + ) { } +} + +export function getDiffChangeType(text: string) { + const c = text[0]; + switch (c) { + case ' ': + return DiffChangeType.Context; + case '+': + return DiffChangeType.Add; + case '-': + return DiffChangeType.Delete; + default: + return DiffChangeType.Control; + } +} + +export class DiffHunk { + public diffLines: DiffLine[] = []; + + constructor( + public oldLineNumber: number, + public oldLength: number, + public newLineNumber: number, + public newLength: number, + public positionInHunk: number, + ) { } +} + +export const DIFF_HUNK_HEADER = /^@@ \-(\d+)(,(\d+))?( \+(\d+)(,(\d+)?)?)? @@/; + +export function countCarriageReturns(text: string): number { + let count = 0; + let index = 0; + while ((index = text.indexOf('\r', index)) !== -1) { + index++; + count++; + } + + return count; +} + +export function* LineReader(text: string): IterableIterator { + let index = 0; + + while (index !== -1 && index < text.length) { + const startIndex = index; + index = text.indexOf('\n', index); + const endIndex = index !== -1 ? index : text.length; + let length = endIndex - startIndex; + + if (index !== -1) { + if (index > 0 && text[index - 1] === '\r') { + length--; + } + + index++; + } + + yield text.substr(startIndex, length); + } +} + +export function* parseDiffHunk(diffHunkPatch: string): IterableIterator { + const lineReader: Iterator = LineReader(diffHunkPatch); + + let itr = lineReader.next(); + let diffHunk: DiffHunk | undefined = undefined; + let positionInHunk = -1; + let oldLine = -1; + let newLine = -1; + + while (!itr.done) { + const line = itr.value; + if (DIFF_HUNK_HEADER.test(line)) { + if (diffHunk) { + yield diffHunk; + diffHunk = undefined; + } + + if (positionInHunk === -1) { + positionInHunk = 0; + } + + const matches = DIFF_HUNK_HEADER.exec(line); + const oriStartLine = (oldLine = Number(matches![1])); + // http://www.gnu.org/software/diffutils/manual/diffutils.html#Detailed-Unified + // `count` is added when the changes have more than 1 line. + const oriLen = Number(matches![3]) || 1; + const newStartLine = (newLine = Number(matches![5])); + const newLen = Number(matches![7]) || 1; + + diffHunk = new DiffHunk(oriStartLine, oriLen, newStartLine, newLen, positionInHunk); + // @rebornix todo, once we have enough tests, this should be removed. + diffHunk.diffLines.push(new DiffLine(DiffChangeType.Control, -1, -1, positionInHunk, line)); + } else if (diffHunk) { + const type = getDiffChangeType(line); + + if (type === DiffChangeType.Control) { + if (diffHunk.diffLines && diffHunk.diffLines.length) { + diffHunk.diffLines[diffHunk.diffLines.length - 1].endwithLineBreak = false; + } + } else { + diffHunk.diffLines.push( + new DiffLine( + type, + type !== DiffChangeType.Add ? oldLine : -1, + type !== DiffChangeType.Delete ? newLine : -1, + positionInHunk, + line, + ), + ); + + const lineCount = 1 + countCarriageReturns(line); + + switch (type) { + case DiffChangeType.Context: + oldLine += lineCount; + newLine += lineCount; + break; + case DiffChangeType.Delete: + oldLine += lineCount; + break; + case DiffChangeType.Add: + newLine += lineCount; + break; + } + } + } + + if (positionInHunk !== -1) { + ++positionInHunk; + } + itr = lineReader.next(); + } + + if (diffHunk) { + yield diffHunk; + } +} + +export function parsePatch(patch: string): DiffHunk[] { + const diffHunkReader = parseDiffHunk(patch); + let diffHunkIter = diffHunkReader.next(); + const diffHunks: DiffHunk[] = []; + + const right: string[] = []; + while (!diffHunkIter.done) { + const diffHunk = diffHunkIter.value; + diffHunks.push(diffHunk); + + for (let j = 0; j < diffHunk.diffLines.length; j++) { + const diffLine = diffHunk.diffLines[j]; + if (diffLine.type === DiffChangeType.Delete || diffLine.type === DiffChangeType.Control) { + } else if (diffLine.type === DiffChangeType.Add) { + right.push(diffLine.text); + } else { + const codeInFirstLine = diffLine.text; + right.push(codeInFirstLine); + } + } + + diffHunkIter = diffHunkReader.next(); + } + + return diffHunks; +} + +export function getModifiedContentFromDiffHunk(originalContent: string, patch: string) { + const left = originalContent.split(/\r?\n/); + const diffHunkReader = parseDiffHunk(patch); + let diffHunkIter = diffHunkReader.next(); + const diffHunks: DiffHunk[] = []; + + const right: string[] = []; + let lastCommonLine = 0; + while (!diffHunkIter.done) { + const diffHunk: DiffHunk = diffHunkIter.value; + diffHunks.push(diffHunk); + + const oriStartLine = diffHunk.oldLineNumber; + + for (let j = lastCommonLine + 1; j < oriStartLine; j++) { + right.push(left[j - 1]); + } + + lastCommonLine = oriStartLine + diffHunk.oldLength - 1; + + for (let j = 0; j < diffHunk.diffLines.length; j++) { + const diffLine = diffHunk.diffLines[j]; + if (diffLine.type === DiffChangeType.Delete || diffLine.type === DiffChangeType.Control) { + } else if (diffLine.type === DiffChangeType.Add) { + right.push(diffLine.text); + } else { + const codeInFirstLine = diffLine.text; + right.push(codeInFirstLine); + } + } + + diffHunkIter = diffHunkReader.next(); + } + + if (lastCommonLine < left.length) { + for (let j = lastCommonLine + 1; j <= left.length; j++) { + right.push(left[j - 1]); + } + } + + return right.join('\n'); +} + +export function getGitChangeType(status: string): GitChangeType { + switch (status) { + case 'removed': + return GitChangeType.DELETE; + case 'added': + return GitChangeType.ADD; + case 'renamed': + return GitChangeType.RENAME; + case 'modified': + return GitChangeType.MODIFY; + default: + return GitChangeType.UNKNOWN; + } +} + +export async function parseDiff( + reviews: IRawFileChange[], + parentCommit: string, +): Promise<(InMemFileChange | SlimFileChange)[]> { + const fileChanges: (InMemFileChange | SlimFileChange)[] = []; + + for (let i = 0; i < reviews.length; i++) { + const review = reviews[i]; + const gitChangeType = getGitChangeType(review.status); + + if (!review.patch && + // We don't need to make a SlimFileChange for empty file adds. + !((gitChangeType === GitChangeType.ADD) && (review.additions === 0))) { + fileChanges.push( + new SlimFileChange( + parentCommit, + review.blob_url, + gitChangeType, + review.filename, + review.previous_filename, + ), + ); + continue; + } + + const diffHunks = review.patch ? parsePatch(review.patch) : []; + fileChanges.push( + new InMemFileChange( + parentCommit, + gitChangeType, + review.filename, + review.previous_filename, + review.patch, + diffHunks, + review.blob_url, + ), + ); + } + + return fileChanges; +} diff --git a/src/common/diffPositionMapping.ts b/src/common/diffPositionMapping.ts index fa582ebd6d..d0aee1a199 100644 --- a/src/common/diffPositionMapping.ts +++ b/src/common/diffPositionMapping.ts @@ -1,98 +1,100 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { DiffChangeType, DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; - -/** - * Line position in a git diff is 1 based, except for the case when the original or changed file have - * no content, in which case it is 0. Normalize the position to be zero based. - * @param line The line in a file from the diff header - */ -export function getZeroBased(line: number): number { - if (line === undefined || line === 0) { - return 0; - } - - return line - 1; -} - -export function getDiffLineByPosition(diffHunks: DiffHunk[], diffLineNumber: number): DiffLine | undefined { - for (let i = 0; i < diffHunks.length; i++) { - const diffHunk = diffHunks[i]; - for (let j = 0; j < diffHunk.diffLines.length; j++) { - if (diffHunk.diffLines[j].positionInHunk === diffLineNumber) { - return diffHunk.diffLines[j]; - } - } - } - - return undefined; -} - -export function mapOldPositionToNew(patch: string, line: number): number { - const diffReader = parseDiffHunk(patch); - let diffIter = diffReader.next(); - - let delta = 0; - while (!diffIter.done) { - const diffHunk: DiffHunk = diffIter.value; - - if (diffHunk.oldLineNumber > line) { - // No-op - } else if (diffHunk.oldLineNumber + diffHunk.oldLength - 1 < line) { - delta += diffHunk.newLength - diffHunk.oldLength; - } else { - // Part of the hunk is before line, part is after. - for (const diffLine of diffHunk.diffLines) { - if (diffLine.oldLineNumber > line) { - return line + delta; - } - if (diffLine.type === DiffChangeType.Add) { - delta++; - } else if (diffLine.type === DiffChangeType.Delete) { - delta--; - } - } - return line + delta; - } - - diffIter = diffReader.next(); - } - - return line + delta; -} - -export function mapNewPositionToOld(patch: string, line: number): number { - const diffReader = parseDiffHunk(patch); - let diffIter = diffReader.next(); - - let delta = 0; - while (!diffIter.done) { - const diffHunk: DiffHunk = diffIter.value; - - if (diffHunk.newLineNumber > line) { - // No-op - } else if (diffHunk.newLineNumber + diffHunk.newLength - 1 < line) { - delta += diffHunk.oldLength - diffHunk.newLength; - } else { - // Part of the hunk is before line, part is after. - for (const diffLine of diffHunk.diffLines) { - if (diffLine.newLineNumber > line) { - return line + delta; - } - if (diffLine.type === DiffChangeType.Add) { - delta--; - } else if (diffLine.type === DiffChangeType.Delete) { - delta++; - } - } - return line + delta; - } - - diffIter = diffReader.next(); - } - - return line + delta; -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { DiffChangeType, DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; + +/** + * Line position in a git diff is 1 based, except for the case when the original or changed file have + * no content, in which case it is 0. Normalize the position to be zero based. + * @param line The line in a file from the diff header + */ +export function getZeroBased(line: number): number { + if (line === undefined || line === 0) { + re + turn 0; + } + + return line - 1; +} + +export function getDiffLineByPosition(diffHunks: DiffHunk[], diffLineNumber: number): DiffLine | undefined { + for (let i = 0; i < diffHunks.length; i++) { + const diffHunk = diffHunks[i]; + for (let j = 0; j < diffHunk.diffLines.length; j++) { + if (diffHunk.diffLines[j].positionInHunk === diffLineNumber) { + return diffHunk.diffLines[j]; + } + } + } + + return undefined; +} + +export function mapOldPositionToNew(patch: string, line: number): number { + const diffReader = parseDiffHunk(patch); + let diffIter = diffReader.next(); + + let delta = 0; + while (!diffIter.done) { + const diffHunk: DiffHunk = diffIter.value; + + if (diffHunk.oldLineNumber > line) { + // No-op + } else if (diffHunk.oldLineNumber + diffHunk.oldLength - 1 < line) { + delta += diffHunk.newLength - diffHunk.oldLength; + } else { + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.oldLineNumber > line) { + return line + delta; + } + if (diffLine.type === DiffChangeType.Add) { + delta++; + } else if (diffLine.type === DiffChangeType.Delete) { + delta--; + } + } + return line + delta; + } + + diffIter = diffReader.next(); + } + + return line + delta; +} + +export function mapNewPositionToOld(patch: string, line: number): number { + const diffReader = parseDiffHunk(patch); + let diffIter = diffReader.next(); + + let delta = 0; + while (!diffIter.done) { + const diffHunk: DiffHunk = diffIter.value; + + if (diffHunk.newLineNumber > line) { + // No-op + } else if (diffHunk.newLineNumber + diffHunk.newLength - 1 < line) { + delta += diffHunk.oldLength - diffHunk.newLength; + } else { + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.newLineNumber > line) { + return line + delta; + } + if (diffLine.type === DiffChangeType.Add) { + delta--; + } else if (diffLine.type === DiffChangeType.Delete) { + delta++; + } + } + return line + delta; + } + + diffIter = diffReader.next(); + } + + return line + delta; +} diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index 9e1290e190..250682870f 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; export namespace contexts { @@ -12,7 +13,8 @@ export namespace contexts { export const REPOS_NOT_IN_REVIEW_MODE = 'github:reposNotInReviewMode'; export const REPOS_IN_REVIEW_MODE = 'github:reposInReviewMode'; export const ACTIVE_PR_COUNT = 'github:activePRCount'; - export const LOADING_PRS_TREE = 'github:loadingPrsTree'; + export const LOAD + ING_PRS_TREE = 'github:loadingPrsTree'; export const LOADING_ISSUES_TREE = 'github:loadingIssuesTree'; export const CREATE_PR_PERMISSIONS = 'github:createPrPermissions'; } @@ -29,4 +31,4 @@ export namespace commands { export function setContext(context: string, value: any) { return executeCommand('setContext', context, value); } -} \ No newline at end of file +} diff --git a/src/common/file.ts b/src/common/file.ts index c352a24137..82c4feae29 100644 --- a/src/common/file.ts +++ b/src/common/file.ts @@ -1,46 +1,48 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { DiffHunk } from './diffHunk'; - -export enum GitChangeType { - ADD, - COPY, - DELETE, - MODIFY, - RENAME, - TYPE, - UNKNOWN, - UNMERGED, -} - -export interface SimpleFileChange { - readonly status: GitChangeType; - readonly fileName: string; - readonly blobUrl: string | undefined; - readonly diffHunks?: DiffHunk[]; -} - -export class InMemFileChange implements SimpleFileChange { - constructor( - public readonly baseCommit: string, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - public readonly patch: string, - public readonly diffHunks: DiffHunk[], - public readonly blobUrl: string, - ) {} -} - -export class SlimFileChange implements SimpleFileChange { - constructor( - public readonly baseCommit: string, - public readonly blobUrl: string, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - ) {} -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { DiffHunk } from './diffHunk'; + +export enum GitChangeType { + ADD, + COPY, + DELETE, + MODIFY, + RENAME, + TYPE, + UNKNOWN, + UNMERGED, + +} + +export interface SimpleFileChange { + readonly status: GitChangeType; + readonly fileName: string; + readonly blobUrl: string | undefined; + readonly diffHunks?: DiffHunk[]; +} + +export class InMemFileChange implements SimpleFileChange { + constructor( + public readonly baseCommit: string, + public readonly status: GitChangeType, + public readonly fileName: string, + public readonly previousFileName: string | undefined, + public readonly patch: string, + public readonly diffHunks: DiffHunk[], + public readonly blobUrl: string, + ) {} +} + +export class SlimFileChange implements SimpleFileChange { + constructor( + public readonly baseCommit: string, + public readonly blobUrl: string, + public readonly status: GitChangeType, + public readonly fileName: string, + public readonly previousFileName: string | undefined, + ) {} +} diff --git a/src/common/githubRef.ts b/src/common/githubRef.ts index eb6847443b..18fabb7f4f 100644 --- a/src/common/githubRef.ts +++ b/src/common/githubRef.ts @@ -1,14 +1,17 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Protocol } from './protocol'; - -export class GitHubRef { - public repositoryCloneUrl: Protocol; - constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: string, - public readonly owner: string, public readonly name: string, public readonly isInOrganization: boolean) { - this.repositoryCloneUrl = new Protocol(repositoryCloneUrl); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Protocol } from './protocol'; + +export class GitHubRef { + public repositoryCloneUrl: Protocol; + + constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: string, + public readonly owner: string, public readonly name: string, public readonly isInOrganization: boolean) { + this.repositoryCloneUrl = new Protocol(repositoryCloneUrl); + } +} + diff --git a/src/common/logger.ts b/src/common/logger.ts index 57bfb0ccd2..431c070914 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -1,71 +1,73 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; - -export const PR_TREE = 'PullRequestTree'; - -class Log { - private _outputChannel: vscode.LogOutputChannel; - private _disposable: vscode.Disposable; - private _activePerfMarkers: Map = new Map(); - - constructor() { - this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request', { log: true }); - } - - public startPerfMarker(marker: string) { - const startTime = performance.now(); - this._outputChannel.appendLine(`PERF_MARKER> Start ${marker}`); - this._activePerfMarkers.set(marker, startTime); - } - - public endPerfMarker(marker: string) { - const endTime = performance.now(); - this._outputChannel.appendLine(`PERF_MARKER> End ${marker}: ${endTime - this._activePerfMarkers.get(marker)!} ms`); - this._activePerfMarkers.delete(marker); - } - - private logString(message: any, component?: string): string { - if (typeof message !== 'string') { - if (message instanceof Error) { - message = message.message; - } else if ('toString' in message) { - message = message.toString(); - } else { - message = JSON.stringify(message); - } - } - return component ? `${component}> ${message}` : message; - } - - public trace(message: any, component: string) { - this._outputChannel.trace(this.logString(message, component)); - } - - public debug(message: any, component: string) { - this._outputChannel.debug(this.logString(message, component)); - } - - public appendLine(message: any, component?: string) { - this._outputChannel.info(this.logString(message, component)); - } - - public warn(message: any, component?: string) { - this._outputChannel.warn(this.logString(message, component)); - } - - public error(message: any, component?: string) { - this._outputChannel.error(this.logString(message, component)); - } - - public dispose() { - if (this._disposable) { - this._disposable.dispose(); - } - } -} - -const Logger = new Log(); -export default Logger; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + + +export const PR_TREE = 'PullRequestTree'; + +class Log { + + private _outputChannel: vscode.LogOutputChannel; + private _disposable: vscode.Disposable; + private _activePerfMarkers: Map = new Map(); + + constructor() { + this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request', { log: true }); + } + + public startPerfMarker(marker: string) { + const startTime = performance.now(); + this._outputChannel.appendLine(`PERF_MARKER> Start ${marker}`); + this._activePerfMarkers.set(marker, startTime); + } + + public endPerfMarker(marker: string) { + const endTime = performance.now(); + this._outputChannel.appendLine(`PERF_MARKER> End ${marker}: ${endTime - this._activePerfMarkers.get(marker)!} ms`); + this._activePerfMarkers.delete(marker); + } + + private logString(message: any, component?: string): string { + if (typeof message !== 'string') { + if (message instanceof Error) { + message = message.message; + } else if ('toString' in message) { + message = message.toString(); + } else { + message = JSON.stringify(message); + } + } + return component ? `${component}> ${message}` : message; + } + + public trace(message: any, component: string) { + this._outputChannel.trace(this.logString(message, component)); + } + + public debug(message: any, component: string) { + this._outputChannel.debug(this.logString(message, component)); + } + + public appendLine(message: any, component?: string) { + this._outputChannel.info(this.logString(message, component)); + } + + public warn(message: any, component?: string) { + this._outputChannel.warn(this.logString(message, component)); + } + + public error(message: any, component?: string) { + this._outputChannel.error(this.logString(message, component)); + } + + public dispose() { + if (this._disposable) { + this._disposable.dispose(); + } + } +} + +const Logger = new Log(); +export default Logger; diff --git a/src/common/persistentState.ts b/src/common/persistentState.ts index 52fba85417..33a0ef62eb 100644 --- a/src/common/persistentState.ts +++ b/src/common/persistentState.ts @@ -1,30 +1,32 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; - -export type GlobalStateContext = { globalState: vscode.Memento }; - -let defaultStorage: vscode.Memento | undefined = undefined; - -export const MISSING = {} as const; - -export function init(ctx: GlobalStateContext) { - defaultStorage = ctx.globalState; -} - -export const fetch = (scope: string, key: string): unknown => { - if (!defaultStorage) { - throw new Error('Persistent store not initialized.'); - } - return defaultStorage.get(scope + ':' + key, MISSING); -}; - -export const store = (scope: string, key: string, value: any) => { - if (!defaultStorage) { - throw new Error('Persistent store not initialized.'); - } - return defaultStorage.update(scope + ':' + key, value); -}; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; + +export type GlobalStateContext = { globalState: vscode.Memento }; + + +let defaultStorage: vscode.Memento | undefined = undefined; + +export const MISSING = {} as const; + +export function init(ctx: GlobalStateContext) { + defaultStorage = ctx.globalState; +} + +export const fetch = (scope: string, key: string): unknown => { + if (!defaultStorage) { + throw new Error('Persistent store not initialized.'); + } + return defaultStorage.get(scope + ':' + key, MISSING); +}; + +export const store = (scope: string, key: string, value: any) => { + if (!defaultStorage) { + throw new Error('Persistent store not initialized.'); + } + return defaultStorage.update(scope + ':' + key, value); +}; diff --git a/src/common/protocol.ts b/src/common/protocol.ts index a3b074f91f..18d7ecd98b 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -1,208 +1,210 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { resolve } from '../env/node/ssh'; -import Logger from './logger'; - - -export enum ProtocolType { - Local, - HTTP, - SSH, - GIT, - OTHER, -} - -export class Protocol { - public type: ProtocolType = ProtocolType.OTHER; - public host: string = ''; - - public owner: string = ''; - - public repositoryName: string = ''; - - public get nameWithOwner(): string { - return this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; - } - - public readonly url: vscode.Uri; - constructor(uriString: string) { - if (this.parseSshProtocol(uriString)) { - return; - } - - try { - this.url = vscode.Uri.parse(uriString); - this.type = this.getType(this.url.scheme); - - this.host = this.getHostName(this.url.authority); - if (this.host) { - this.repositoryName = this.getRepositoryName(this.url.path) || ''; - this.owner = this.getOwnerName(this.url.path) || ''; - } - } catch (e) { - Logger.error(`Failed to parse '${uriString}'`); - vscode.window.showWarningMessage( - vscode.l10n.t('Unable to parse remote \'{0}\'. Please check that it is correctly formatted.', uriString) - ); - } - } - - private getType(scheme: string): ProtocolType { - switch (scheme) { - case 'file': - return ProtocolType.Local; - case 'http': - case 'https': - return ProtocolType.HTTP; - case 'git': - return ProtocolType.GIT; - case 'ssh': - return ProtocolType.SSH; - default: - return ProtocolType.OTHER; - } - } - - private parseSshProtocol(uriString: string): boolean { - const sshConfig = resolve(uriString); - if (!sshConfig) { - return false; - } - const { Hostname, HostName, path } = sshConfig; - this.host = HostName || Hostname; - this.owner = this.getOwnerName(path) || ''; - this.repositoryName = this.getRepositoryName(path) || ''; - this.type = ProtocolType.SSH; - return true; - } - - getHostName(authority: string) { - // :@: - const matches = /^(?:.*:?@)?([^:]*)(?::.*)?$/.exec(authority); - - if (matches && matches.length >= 2) { - // normalize to fix #903. - // www.github.com will redirect anyways, so this is safe in this specific case, but potentially not in others. - return matches[1].toLocaleLowerCase() === 'www.github.com' ? 'github.com' : matches[1]; - } - - return ''; - } - - getRepositoryName(path: string) { - let normalized = path.replace(/\\/g, '/'); - if (normalized.endsWith('/')) { - normalized = normalized.substr(0, normalized.length - 1); - } - const lastIndex = normalized.lastIndexOf('/'); - const lastSegment = normalized.substr(lastIndex + 1); - if (lastSegment === '' || lastSegment === '/') { - return; - } - - return lastSegment.replace(/\/$/, '').replace(/\.git$/, ''); - } - - getOwnerName(path: string) { - let normalized = path.replace(/\\/g, '/'); - if (normalized.endsWith('/')) { - normalized = normalized.substr(0, normalized.length - 1); - } - - const fragments = normalized.split('/'); - if (fragments.length > 1) { - return fragments[fragments.length - 2]; - } - - return; - } - - normalizeUri(): vscode.Uri | undefined { - if (this.type === ProtocolType.OTHER && !this.url) { - return; - } - - if (this.type === ProtocolType.Local) { - return this.url; - } - - let scheme = 'https'; - if (this.url && (this.url.scheme === 'http' || this.url.scheme === 'https')) { - scheme = this.url.scheme; - } - - try { - return vscode.Uri.parse( - `${scheme}://${this.host.toLocaleLowerCase()}/${this.nameWithOwner.toLocaleLowerCase()}`, - ); - } catch (e) { - return; - } - } - - toString(): string | undefined { - // based on Uri scheme for SSH https://tools.ietf.org/id/draft-salowey-secsh-uri-00.html#anchor1 and heuristics of how GitHub handles ssh url - // sshUri = `ssh:` - // - omitted - // hier-part = "//" authority path-abempty - // - // is omitted - // authority = [ [ ssh-info ] "@" host ] [ ":" port] - // - ssh-info: git - // - host: ${this.host} - // - port: omitted - // path-abempty = - // - we use relative path here `${this.owner}/${this.repositoryName}` - if (this.type === ProtocolType.SSH) { - return `git@${this.host}:${this.owner}/${this.repositoryName}`; - } - - if (this.type === ProtocolType.GIT) { - return `git://git@${this.host}:${this.owner}/${this.repositoryName}`; - } - - const normalizedUri = this.normalizeUri(); - if (normalizedUri) { - return normalizedUri.toString(); - } - - return; - } - - update(change: { type?: ProtocolType; host?: string; owner?: string; repositoryName?: string }): Protocol { - if (change.type) { - this.type = change.type; - } - - if (change.host) { - this.host = change.host; - } - - if (change.owner) { - this.owner = change.owner; - } - - if (change.repositoryName) { - this.repositoryName = change.repositoryName; - } - - return this; - } - - equals(other: Protocol) { - const normalizeUri = this.normalizeUri(); - if (!normalizeUri) { - return false; - } - - const otherNormalizeUri = other.normalizeUri(); - if (!otherNormalizeUri) { - return false; - } - - return normalizeUri.toString().toLocaleLowerCase() === otherNormalizeUri.toString().toLocaleLowerCase(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { resolve } from '../env/node/ssh'; +import Logger from './logger'; + + +export enum ProtocolType { + Local, + HTTP, + SSH, + + GIT, + OTHER, +} + +export class Protocol { + public type: ProtocolType = ProtocolType.OTHER; + public host: string = ''; + + public owner: string = ''; + + public repositoryName: string = ''; + + public get nameWithOwner(): string { + return this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; + } + + public readonly url: vscode.Uri; + constructor(uriString: string) { + if (this.parseSshProtocol(uriString)) { + return; + } + + try { + this.url = vscode.Uri.parse(uriString); + this.type = this.getType(this.url.scheme); + + this.host = this.getHostName(this.url.authority); + if (this.host) { + this.repositoryName = this.getRepositoryName(this.url.path) || ''; + this.owner = this.getOwnerName(this.url.path) || ''; + } + } catch (e) { + Logger.error(`Failed to parse '${uriString}'`); + vscode.window.showWarningMessage( + vscode.l10n.t('Unable to parse remote \'{0}\'. Please check that it is correctly formatted.', uriString) + ); + } + } + + private getType(scheme: string): ProtocolType { + switch (scheme) { + case 'file': + return ProtocolType.Local; + case 'http': + case 'https': + return ProtocolType.HTTP; + case 'git': + return ProtocolType.GIT; + case 'ssh': + return ProtocolType.SSH; + default: + return ProtocolType.OTHER; + } + } + + private parseSshProtocol(uriString: string): boolean { + const sshConfig = resolve(uriString); + if (!sshConfig) { + return false; + } + const { Hostname, HostName, path } = sshConfig; + this.host = HostName || Hostname; + this.owner = this.getOwnerName(path) || ''; + this.repositoryName = this.getRepositoryName(path) || ''; + this.type = ProtocolType.SSH; + return true; + } + + getHostName(authority: string) { + // :@: + const matches = /^(?:.*:?@)?([^:]*)(?::.*)?$/.exec(authority); + + if (matches && matches.length >= 2) { + // normalize to fix #903. + // www.github.com will redirect anyways, so this is safe in this specific case, but potentially not in others. + return matches[1].toLocaleLowerCase() === 'www.github.com' ? 'github.com' : matches[1]; + } + + return ''; + } + + getRepositoryName(path: string) { + let normalized = path.replace(/\\/g, '/'); + if (normalized.endsWith('/')) { + normalized = normalized.substr(0, normalized.length - 1); + } + const lastIndex = normalized.lastIndexOf('/'); + const lastSegment = normalized.substr(lastIndex + 1); + if (lastSegment === '' || lastSegment === '/') { + return; + } + + return lastSegment.replace(/\/$/, '').replace(/\.git$/, ''); + } + + getOwnerName(path: string) { + let normalized = path.replace(/\\/g, '/'); + if (normalized.endsWith('/')) { + normalized = normalized.substr(0, normalized.length - 1); + } + + const fragments = normalized.split('/'); + if (fragments.length > 1) { + return fragments[fragments.length - 2]; + } + + return; + } + + normalizeUri(): vscode.Uri | undefined { + if (this.type === ProtocolType.OTHER && !this.url) { + return; + } + + if (this.type === ProtocolType.Local) { + return this.url; + } + + let scheme = 'https'; + if (this.url && (this.url.scheme === 'http' || this.url.scheme === 'https')) { + scheme = this.url.scheme; + } + + try { + return vscode.Uri.parse( + `${scheme}://${this.host.toLocaleLowerCase()}/${this.nameWithOwner.toLocaleLowerCase()}`, + ); + } catch (e) { + return; + } + } + + toString(): string | undefined { + // based on Uri scheme for SSH https://tools.ietf.org/id/draft-salowey-secsh-uri-00.html#anchor1 and heuristics of how GitHub handles ssh url + // sshUri = `ssh:` + // - omitted + // hier-part = "//" authority path-abempty + // - // is omitted + // authority = [ [ ssh-info ] "@" host ] [ ":" port] + // - ssh-info: git + // - host: ${this.host} + // - port: omitted + // path-abempty = + // - we use relative path here `${this.owner}/${this.repositoryName}` + if (this.type === ProtocolType.SSH) { + return `git@${this.host}:${this.owner}/${this.repositoryName}`; + } + + if (this.type === ProtocolType.GIT) { + return `git://git@${this.host}:${this.owner}/${this.repositoryName}`; + } + + const normalizedUri = this.normalizeUri(); + if (normalizedUri) { + return normalizedUri.toString(); + } + + return; + } + + update(change: { type?: ProtocolType; host?: string; owner?: string; repositoryName?: string }): Protocol { + if (change.type) { + this.type = change.type; + } + + if (change.host) { + this.host = change.host; + } + + if (change.owner) { + this.owner = change.owner; + } + + if (change.repositoryName) { + this.repositoryName = change.repositoryName; + } + + return this; + } + + equals(other: Protocol) { + const normalizeUri = this.normalizeUri(); + if (!normalizeUri) { + return false; + } + + const otherNormalizeUri = other.normalizeUri(); + if (!otherNormalizeUri) { + return false; + } + + return normalizeUri.toString().toLocaleLowerCase() === otherNormalizeUri.toString().toLocaleLowerCase(); + } +} diff --git a/src/common/remote.ts b/src/common/remote.ts index cdfab74a12..af976317a3 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -1,110 +1,112 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Repository } from '../api/api'; -import { getEnterpriseUri, isEnterprise } from '../github/utils'; -import { AuthProvider, GitHubServerType } from './authentication'; -import { Protocol } from './protocol'; - -export class Remote { - public get host(): string { - return this.gitProtocol.host; - } - public get owner(): string { - return this.gitProtocol.owner; - } - public get repositoryName(): string { - return this.gitProtocol.repositoryName; - } - - public get normalizedHost(): string { - const normalizedUri = this.gitProtocol.normalizeUri(); - return `${normalizedUri!.scheme}://${normalizedUri!.authority}`; - } - - public get authProviderId(): AuthProvider { - return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; - } - - public get isEnterprise(): boolean { - return isEnterprise(this.authProviderId); - } - - constructor( - public readonly remoteName: string, - public readonly url: string, - public readonly gitProtocol: Protocol, - ) { } - - equals(remote: Remote): boolean { - if (this.remoteName !== remote.remoteName) { - return false; - } - if (this.host !== remote.host) { - return false; - } - if (this.owner.toLocaleLowerCase() !== remote.owner.toLocaleLowerCase()) { - return false; - } - if (this.repositoryName.toLocaleLowerCase() !== remote.repositoryName.toLocaleLowerCase()) { - return false; - } - - return true; - } -} - -export function parseRemote(remoteName: string, url: string, originalProtocol?: Protocol): Remote | null { - if (!url) { - return null; - } - const gitProtocol = new Protocol(url); - if (originalProtocol) { - gitProtocol.update({ - type: originalProtocol.type, - }); - } - - if (gitProtocol.host) { - return new Remote(remoteName, url, gitProtocol); - } - - return null; -} - -export function parseRepositoryRemotes(repository: Repository): Remote[] { - const remotes: Remote[] = []; - for (const r of repository.state.remotes) { - const urls: string[] = []; - if (r.fetchUrl) { - urls.push(r.fetchUrl); - } - if (r.pushUrl && r.pushUrl !== r.fetchUrl) { - urls.push(r.pushUrl); - } - urls.forEach(url => { - const remote = parseRemote(r.name, url); - if (remote) { - remotes.push(remote); - } - }); - } - return remotes; -} - -export class GitHubRemote extends Remote { - static remoteAsGitHub(remote: Remote, githubServerType: GitHubServerType): GitHubRemote { - return new GitHubRemote(remote.remoteName, remote.url, remote.gitProtocol, githubServerType); - } - - constructor( - remoteName: string, - url: string, - gitProtocol: Protocol, - public readonly githubServerType: GitHubServerType - ) { - super(remoteName, url, gitProtocol); - } -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Repository } from '../api/api'; +import { getEnterpriseUri, isEnterprise } from '../github/utils'; +import { AuthProvider, GitHubServerType } from './authentication'; +import { Protocol } from './protocol'; + +export class Remote { + public get host(): string { + return this.gitProtocol.host; + } + public get owner(): string { + return this.gitProtocol.owner; + } + + public get repositoryName(): string { + return this.gitProtocol.repositoryName; + } + + public get normalizedHost(): string { + const normalizedUri = this.gitProtocol.normalizeUri(); + return `${normalizedUri!.scheme}://${normalizedUri!.authority}`; + } + + public get authProviderId(): AuthProvider { + return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; + } + + public get isEnterprise(): boolean { + return isEnterprise(this.authProviderId); + } + + constructor( + public readonly remoteName: string, + public readonly url: string, + public readonly gitProtocol: Protocol, + ) { } + + equals(remote: Remote): boolean { + if (this.remoteName !== remote.remoteName) { + return false; + } + if (this.host !== remote.host) { + return false; + } + if (this.owner.toLocaleLowerCase() !== remote.owner.toLocaleLowerCase()) { + return false; + } + if (this.repositoryName.toLocaleLowerCase() !== remote.repositoryName.toLocaleLowerCase()) { + return false; + } + + return true; + } +} + +export function parseRemote(remoteName: string, url: string, originalProtocol?: Protocol): Remote | null { + if (!url) { + return null; + } + const gitProtocol = new Protocol(url); + if (originalProtocol) { + gitProtocol.update({ + type: originalProtocol.type, + }); + } + + if (gitProtocol.host) { + return new Remote(remoteName, url, gitProtocol); + } + + return null; +} + +export function parseRepositoryRemotes(repository: Repository): Remote[] { + const remotes: Remote[] = []; + for (const r of repository.state.remotes) { + const urls: string[] = []; + if (r.fetchUrl) { + urls.push(r.fetchUrl); + } + if (r.pushUrl && r.pushUrl !== r.fetchUrl) { + urls.push(r.pushUrl); + } + urls.forEach(url => { + const remote = parseRemote(r.name, url); + if (remote) { + remotes.push(remote); + } + }); + } + return remotes; +} + +export class GitHubRemote extends Remote { + static remoteAsGitHub(remote: Remote, githubServerType: GitHubServerType): GitHubRemote { + return new GitHubRemote(remote.remoteName, remote.url, remote.gitProtocol, githubServerType); + } + + constructor( + remoteName: string, + url: string, + gitProtocol: Protocol, + public readonly githubServerType: GitHubServerType + ) { + super(remoteName, url, gitProtocol); + } +} diff --git a/src/common/resources.ts b/src/common/resources.ts index cff11666af..d9ff4ca27a 100644 --- a/src/common/resources.ts +++ b/src/common/resources.ts @@ -1,26 +1,28 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; - -export class Resource { - static icons: any; - - static initialize(context: vscode.ExtensionContext) { - Resource.icons = { - reactions: { - THUMBS_UP: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_up.png')), - THUMBS_DOWN: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_down.png')), - CONFUSED: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'confused.png')), - EYES: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'eyes.png')), - HEART: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'heart.png')), - HOORAY: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'hooray.png')), - LAUGH: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'laugh.png')), - ROCKET: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'rocket.png')), - }, - }; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; + +export class Resource { + static icons: any; + + static initialize(context: vscode.ExtensionContext) { + Resource.icons = { + + reactions: { + THUMBS_UP: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_up.png')), + THUMBS_DOWN: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_down.png')), + CONFUSED: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'confused.png')), + EYES: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'eyes.png')), + HEART: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'heart.png')), + HOORAY: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'hooray.png')), + LAUGH: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'laugh.png')), + ROCKET: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'rocket.png')), + }, + }; + } +} diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 8ab9f20fdb..a47674bfa4 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -1,73 +1,75 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const PR_SETTINGS_NAMESPACE = 'githubPullRequests'; -export const TERMINAL_LINK_HANDLER = 'terminalLinksHandler'; -export const BRANCH_PUBLISH = 'createOnPublishBranch'; -export const USE_REVIEW_MODE = 'useReviewMode'; -export const FILE_LIST_LAYOUT = 'fileListLayout'; -export const ASSIGN_TO = 'assignCreated'; -export const PUSH_BRANCH = 'pushBranch'; -export const IGNORE_PR_BRANCHES = 'ignoredPullRequestBranches'; -export const NEVER_IGNORE_DEFAULT_BRANCH = 'neverIgnoreDefaultBranch'; -export const OVERRIDE_DEFAULT_BRANCH = 'overrideDefaultBranch'; -export const PULL_BRANCH = 'pullBranch'; -export const PULL_REQUEST_DESCRIPTION = 'pullRequestDescription'; -export const NOTIFICATION_SETTING = 'notifications'; -export const POST_CREATE = 'postCreate'; -export const QUERIES = 'queries'; -export const FOCUSED_MODE = 'focusedMode'; -export const CREATE_DRAFT = 'createDraft'; -export const QUICK_DIFF = 'quickDiff'; -export const SET_AUTO_MERGE = 'setAutoMerge'; -export const SHOW_PULL_REQUEST_NUMBER_IN_TREE = 'showPullRequestNumberInTree'; -export const DEFAULT_MERGE_METHOD = 'defaultMergeMethod'; -export const DEFAULT_DELETION_METHOD = 'defaultDeletionMethod'; -export const SELECT_LOCAL_BRANCH = 'selectLocalBranch'; -export const SELECT_REMOTE = 'selectRemote'; -export const REMOTES = 'remotes'; -export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout'; -export const UPSTREAM_REMOTE = 'upstreamRemote'; -export const DEFAULT_CREATE_OPTION = 'defaultCreateOption'; -export const CREATE_BASE_BRANCH = 'createDefaultBaseBranch'; - -export const ISSUES_SETTINGS_NAMESPACE = 'githubIssues'; -export const ASSIGN_WHEN_WORKING = 'assignWhenWorking'; -export const ISSUE_COMPLETIONS = 'issueCompletions'; -export const USER_COMPLETIONS = 'userCompletions'; -export const ENABLED = 'enabled'; -export const IGNORE_USER_COMPLETION_TRIGGER = 'ignoreUserCompletionTrigger'; -export const CREATE_INSERT_FORMAT = 'createInsertFormat'; -export const ISSUE_BRANCH_TITLE = 'issueBranchTitle'; -export const USE_BRANCH_FOR_ISSUES = 'useBranchForIssues'; -export const WORKING_ISSUE_FORMAT_SCM = 'workingIssueFormatScm'; -export const IGNORE_COMPLETION_TRIGGER = 'ignoreCompletionTrigger'; -export const ISSUE_COMPLETION_FORMAT_SCM = 'issueCompletionFormatScm'; -export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers'; -export const DEFAULT = 'default'; -export const IGNORE_MILESTONES = 'ignoreMilestones'; -export const ALLOW_FETCH = 'allowFetch'; - -// git -export const GIT = 'git'; -export const PULL_BEFORE_CHECKOUT = 'pullBeforeCheckout'; -export const OPEN_DIFF_ON_CLICK = 'openDiffOnClick'; -export const AUTO_STASH = 'autoStash'; - -// GitHub Enterprise -export const GITHUB_ENTERPRISE = 'github-enterprise'; -export const URI = 'uri'; - -// Editor -export const EDITOR = 'editor'; -export const WORD_WRAP = 'wordWrap'; - -// Comments -export const COMMENTS = 'comments'; -export const OPEN_VIEW = 'openView'; - -// Explorer -export const EXPLORER = 'explorer'; -export const AUTO_REVEAL = 'autoReveal'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export const PR_SETTINGS_NAMESPACE = 'githubPullRequests'; +export const TERMINAL_LINK_HANDLER = 'terminalLinksHandler'; +export const BRANCH_PUBLISH = 'createOnPublishBranch'; +export const USE_REVIEW_MODE = 'useReviewMode'; +export const FILE_LIST_LAYOUT = 'fileListLayout'; +export const ASSIGN_TO = 'assignCreated'; +export const PUSH_BRANCH = 'pushBranch'; +export const IGNORE_PR_BRANCHES = 'ignoredPullRequestBranches'; +export const NEVER_IGNORE_DEFAULT_BRANCH = 'neverIgnoreDefaultBranch'; +export const OVERRIDE_DEFAULT_BRANCH = 'overrideDefaultBranch'; +export const PULL_BRANCH = 'pullBranch'; +export const PULL_REQUEST_DESCRIPTION = 'pullRequestDescription'; +export const NOTIFICATION_SETTING = 'notifications'; + +export const POST_CREATE = 'postCreate'; +export const QUERIES = 'queries'; +export const FOCUSED_MODE = 'focusedMode'; +export const CREATE_DRAFT = 'createDraft'; +export const QUICK_DIFF = 'quickDiff'; +export const SET_AUTO_MERGE = 'setAutoMerge'; +export const SHOW_PULL_REQUEST_NUMBER_IN_TREE = 'showPullRequestNumberInTree'; +export const DEFAULT_MERGE_METHOD = 'defaultMergeMethod'; +export const DEFAULT_DELETION_METHOD = 'defaultDeletionMethod'; +export const SELECT_LOCAL_BRANCH = 'selectLocalBranch'; +export const SELECT_REMOTE = 'selectRemote'; +export const REMOTES = 'remotes'; +export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout'; +export const UPSTREAM_REMOTE = 'upstreamRemote'; +export const DEFAULT_CREATE_OPTION = 'defaultCreateOption'; +export const CREATE_BASE_BRANCH = 'createDefaultBaseBranch'; + +export const ISSUES_SETTINGS_NAMESPACE = 'githubIssues'; +export const ASSIGN_WHEN_WORKING = 'assignWhenWorking'; +export const ISSUE_COMPLETIONS = 'issueCompletions'; +export const USER_COMPLETIONS = 'userCompletions'; +export const ENABLED = 'enabled'; +export const IGNORE_USER_COMPLETION_TRIGGER = 'ignoreUserCompletionTrigger'; +export const CREATE_INSERT_FORMAT = 'createInsertFormat'; +export const ISSUE_BRANCH_TITLE = 'issueBranchTitle'; +export const USE_BRANCH_FOR_ISSUES = 'useBranchForIssues'; +export const WORKING_ISSUE_FORMAT_SCM = 'workingIssueFormatScm'; +export const IGNORE_COMPLETION_TRIGGER = 'ignoreCompletionTrigger'; +export const ISSUE_COMPLETION_FORMAT_SCM = 'issueCompletionFormatScm'; +export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers'; +export const DEFAULT = 'default'; +export const IGNORE_MILESTONES = 'ignoreMilestones'; +export const ALLOW_FETCH = 'allowFetch'; + +// git +export const GIT = 'git'; +export const PULL_BEFORE_CHECKOUT = 'pullBeforeCheckout'; +export const OPEN_DIFF_ON_CLICK = 'openDiffOnClick'; +export const AUTO_STASH = 'autoStash'; + +// GitHub Enterprise +export const GITHUB_ENTERPRISE = 'github-enterprise'; +export const URI = 'uri'; + +// Editor +export const EDITOR = 'editor'; +export const WORD_WRAP = 'wordWrap'; + +// Comments +export const COMMENTS = 'comments'; +export const OPEN_VIEW = 'openView'; + +// Explorer +export const EXPLORER = 'explorer'; +export const AUTO_REVEAL = 'autoReveal'; diff --git a/src/common/telemetry.ts b/src/common/telemetry.ts index 77944654a1..f63ee23762 100644 --- a/src/common/telemetry.ts +++ b/src/common/telemetry.ts @@ -1,26 +1,28 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export interface ITelemetry { - sendTelemetryEvent( - eventName: string, - properties?: { - [key: string]: string; - }, - measurements?: { - [key: string]: number; - }, - ): void; - sendTelemetryErrorEvent( - eventName: string, - properties?: { - [key: string]: string; - }, - measurements?: { - [key: string]: number; - }, - ): void; - dispose(): Promise; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export interface ITelemetry { + sendTelemetryEvent( + eventName: string, + properties?: { + [key: string]: string; + }, + measurements?: { + [key: string]: number; + + }, + ): void; + sendTelemetryErrorEvent( + eventName: string, + properties?: { + [key: string]: string; + }, + measurements?: { + [key: string]: number; + }, + ): void; + dispose(): Promise; +} diff --git a/src/common/temporaryState.ts b/src/common/temporaryState.ts index c73288317f..a0094500a1 100644 --- a/src/common/temporaryState.ts +++ b/src/common/temporaryState.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import Logger from './logger'; import { dispose } from './utils'; @@ -11,6 +12,7 @@ let tempState: TemporaryState | undefined; export class TemporaryState extends vscode.Disposable { private readonly SUBPATH = 'temp'; private readonly disposables: vscode.Disposable[] = []; + private readonly persistInSessionDisposables: vscode.Disposable[] = []; constructor(private _storageUri: vscode.Uri) { @@ -113,4 +115,4 @@ export class TemporaryState extends vscode.Disposable { return tempState.readState(subpath, filename); } -} \ No newline at end of file +} diff --git a/src/common/timelineEvent.ts b/src/common/timelineEvent.ts index ba59aeffe7..0e2c443c72 100644 --- a/src/common/timelineEvent.ts +++ b/src/common/timelineEvent.ts @@ -1,107 +1,108 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IAccount, IActor } from '../github/interface'; -import { IComment } from './comment'; - -export enum EventType { - Committed, - Mentioned, - Subscribed, - Commented, - Reviewed, - NewCommitsSinceReview, - Labeled, - Milestoned, - Assigned, - HeadRefDeleted, - Merged, - Other, -} - -export interface Committer { - date: string; - name: string; - email: string; -} - -export interface CommentEvent { - id: number; - graphNodeId: string; - htmlUrl: string; - body: string; - bodyHTML?: string; - user: IAccount; - event: EventType.Commented; - canEdit?: boolean; - canDelete?: boolean; - createdAt: string; -} - -export interface ReviewResolveInfo { - threadId: string; - canResolve: boolean; - canUnresolve: boolean; - isResolved: boolean; -} - -export interface ReviewEvent { - id: number; - reviewThread?: ReviewResolveInfo - event: EventType.Reviewed; - comments: IComment[]; - submittedAt: string; - body: string; - bodyHTML?: string; - htmlUrl: string; - user: IAccount; - authorAssociation: string; - state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; -} - -export interface CommitEvent { - id: string; - author: IAccount; - event: EventType.Committed; - sha: string; - htmlUrl: string; - message: string; - bodyHTML?: string; - authoredDate: Date; -} - -export interface NewCommitsSinceReviewEvent { - id: string; - event: EventType.NewCommitsSinceReview; -} - -export interface MergedEvent { - id: string; - graphNodeId: string; - user: IActor; - createdAt: string; - mergeRef: string; - sha: string; - commitUrl: string; - event: EventType.Merged; - url: string; -} - -export interface AssignEvent { - id: number; - event: EventType.Assigned; - user: IAccount; - actor: IActor; -} - -export interface HeadRefDeleteEvent { - id: string; - event: EventType.HeadRefDeleted; - actor: IActor; - createdAt: string; - headRef: string; -} - -export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | HeadRefDeleteEvent; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { IAccount, IActor } from '../github/interface'; +import { IComment } from './comment'; + +export enum EventType { + Committed, + Mentioned, + Subscribed, + Commented, + Reviewed, + NewCommitsSinceReview, + Labeled, + Milestoned, + Assigned, + HeadRefDeleted, + Merged, + Other, +} + +export interface Committer { + date: string; + name: string; + email: string; +} + +export interface CommentEvent { + id: number; + graphNodeId: string; + htmlUrl: string; + body: string; + bodyHTML?: string; + user: IAccount; + event: EventType.Commented; + canEdit?: boolean; + canDelete?: boolean; + createdAt: string; +} + +export interface ReviewResolveInfo { + threadId: string; + canResolve: boolean; + canUnresolve: boolean; + isResolved: boolean; +} + +export interface ReviewEvent { + id: number; + reviewThread?: ReviewResolveInfo + event: EventType.Reviewed; + comments: IComment[]; + submittedAt: string; + body: string; + bodyHTML?: string; + htmlUrl: string; + user: IAccount; + authorAssociation: string; + state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; +} + +export interface CommitEvent { + id: string; + author: IAccount; + event: EventType.Committed; + sha: string; + htmlUrl: string; + message: string; + bodyHTML?: string; + authoredDate: Date; +} + +export interface NewCommitsSinceReviewEvent { + id: string; + event: EventType.NewCommitsSinceReview; +} + +export interface MergedEvent { + id: string; + graphNodeId: string; + user: IActor; + createdAt: string; + mergeRef: string; + sha: string; + commitUrl: string; + event: EventType.Merged; + url: string; +} + +export interface AssignEvent { + id: number; + event: EventType.Assigned; + user: IAccount; + actor: IActor; +} + +export interface HeadRefDeleteEvent { + id: string; + event: EventType.HeadRefDeleted; + actor: IActor; + createdAt: string; + headRef: string; +} + +export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | HeadRefDeleteEvent; diff --git a/src/common/uri.ts b/src/common/uri.ts index 30b31d404d..aea6758541 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -1,420 +1,421 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; - -import { Buffer } from 'buffer'; -import * as pathUtils from 'path'; -import fetch from 'cross-fetch'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { IAccount, ITeam, reviewerId } from '../github/interface'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { GitChangeType } from './file'; -import Logger from './logger'; -import { TemporaryState } from './temporaryState'; - -export interface ReviewUriParams { - path: string; - ref?: string; - commit?: string; - base: boolean; - isOutdated: boolean; - rootPath: string; -} - -export function fromReviewUri(query: string): ReviewUriParams { - return JSON.parse(query); -} - -export interface PRUriParams { - baseCommit: string; - headCommit: string; - isBase: boolean; - fileName: string; - prNumber: number; - status: GitChangeType; - remoteName: string; - previousFileName?: string; -} - -export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { - try { - return JSON.parse(uri.query) as PRUriParams; - } catch (e) { } -} - -export interface PRNodeUriParams { - prIdentifier: string -} - -export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { - try { - return JSON.parse(uri.query) as PRNodeUriParams; - } catch (e) { } -} - -export interface GitHubUriParams { - fileName: string; - branch: string; - isEmpty?: boolean; -} -export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined { - try { - return JSON.parse(uri.query) as GitHubUriParams; - } catch (e) { } -} - -export interface GitUriOptions { - replaceFileExtension?: boolean; - submoduleOf?: string; - base: boolean; -} - -const ImageMimetypes = ['image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/tiff', 'image/bmp']; -// Known media types that VS Code can handle: https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/base/common/mime.ts#L33-L84 -export const KnownMediaExtensions = [ - '.aac', - '.avi', - '.bmp', - '.flv', - '.gif', - '.ico', - '.jpe', - '.jpeg', - '.jpg', - '.m1v', - '.m2a', - '.m2v', - '.m3a', - '.mid', - '.midi', - '.mk3d', - '.mks', - '.mkv', - '.mov', - '.movie', - '.mp2', - '.mp2a', - '.mp3', - '.mp4', - '.mp4a', - '.mp4v', - '.mpe', - '.mpeg', - '.mpg', - '.mpg4', - '.mpga', - '.oga', - '.ogg', - '.opus', - '.ogv', - '.png', - '.psd', - '.qt', - '.spx', - '.svg', - '.tga', - '.tif', - '.tiff', - '.wav', - '.webm', - '.webp', - '.wma', - '.wmv', - '.woff' -]; - -// a 1x1 pixel transparent gif, from http://png-pixel.com/ -export const EMPTY_IMAGE_URI = vscode.Uri.parse( - `data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`, -); - -export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): Promise { - try { - const { commit, baseCommit, headCommit, isBase, path }: { commit: string, baseCommit: string, headCommit: string, isBase: string, path: string } = JSON.parse(uri.query); - const ext = pathUtils.extname(path); - if (!KnownMediaExtensions.includes(ext)) { - return; - } - const ref = uri.scheme === Schemes.Review ? commit : isBase ? baseCommit : headCommit; - const { object } = await repository.getObjectDetails(ref, uri.fsPath); - const { mimetype } = await repository.detectObjectType(object); - - if (mimetype === 'text/plain') { - return; - } - - if (ImageMimetypes.indexOf(mimetype) > -1) { - const contents = await repository.buffer(ref, uri.fsPath); - return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents); - } - } catch (err) { - return; - } -} - -export namespace DataUri { - const iconsFolder = 'userIcons'; - - function iconFilename(user: IAccount | ITeam): string { - return `${reviewerId(user)}.jpg`; - } - - function cacheLocation(context: vscode.ExtensionContext): vscode.Uri { - return vscode.Uri.joinPath(context.globalStorageUri, iconsFolder); - } - - function fileCacheUri(context: vscode.ExtensionContext, user: IAccount | ITeam): vscode.Uri { - return vscode.Uri.joinPath(cacheLocation(context), iconFilename(user)); - } - - function cacheLogUri(context: vscode.ExtensionContext): vscode.Uri { - return vscode.Uri.joinPath(cacheLocation(context), 'cache.log'); - } - - async function writeAvatarToCache(context: vscode.ExtensionContext, user: IAccount | ITeam, contents: Uint8Array): Promise { - await vscode.workspace.fs.createDirectory(cacheLocation(context)); - const file = fileCacheUri(context, user); - await vscode.workspace.fs.writeFile(file, contents); - return file; - } - - async function readAvatarFromCache(context: vscode.ExtensionContext, user: IAccount | ITeam): Promise { - try { - const file = fileCacheUri(context, user); - return vscode.workspace.fs.readFile(file); - } catch (e) { - return; - } - } - - export function asImageDataURI(contents: Buffer): vscode.Uri { - return vscode.Uri.parse( - `data:image/svg+xml;size:${contents.byteLength};base64,${contents.toString('base64')}` - ); - } - - export async function avatarCirclesAsImageDataUris(context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean): Promise<(vscode.Uri | undefined)[]> { - let cacheLogOrder: string[]; - const cacheLog = cacheLogUri(context); - try { - const log = await vscode.workspace.fs.readFile(cacheLog); - cacheLogOrder = JSON.parse(log.toString()); - } catch (e) { - cacheLogOrder = []; - } - const startingCacheSize = cacheLogOrder.length; - - const results = await Promise.all(users.map(async (user) => { - - const imageSourceUrl = user.avatarUrl; - if (imageSourceUrl === undefined) { - return undefined; - } - let innerImageContents: Buffer | undefined; - let cacheMiss: boolean = false; - try { - const fileContents = await readAvatarFromCache(context, user); - if (!fileContents) { - throw new Error('Temporary state not initialized'); - } - innerImageContents = Buffer.from(fileContents); - } catch (e) { - if (localOnly) { - return; - } - cacheMiss = true; - const doFetch = async () => { - const response = await fetch(imageSourceUrl.toString()); - const buffer = await response.arrayBuffer(); - await writeAvatarToCache(context, user, new Uint8Array(buffer)); - innerImageContents = Buffer.from(buffer); - }; - try { - await doFetch(); - } catch (e) { - // We retry once. - await doFetch(); - } - } - if (!innerImageContents) { - return undefined; - } - if (cacheMiss) { - const icon = iconFilename(user); - cacheLogOrder.push(icon); - } - const innerImageEncoded = `data:image/jpeg;size:${innerImageContents.byteLength};base64,${innerImageContents.toString('base64')}`; - const contentsString = ` - - `; - const contents = Buffer.from(contentsString); - const finalDataUri = asImageDataURI(contents); - return finalDataUri; - })); - - const maxCacheSize = Math.max(users.length, 200); - if (cacheLogOrder.length > startingCacheSize && startingCacheSize > 0 && cacheLogOrder.length > maxCacheSize) { - // The cache is getting big, we should clean it up. - const toDelete = cacheLogOrder.splice(0, 50); - await Promise.all(toDelete.map(async (id) => { - try { - await vscode.workspace.fs.delete(vscode.Uri.joinPath(cacheLocation(context), id)); - } catch (e) { - Logger.error(`Failed to delete avatar from cache: ${e}`); - } - })); - } - - await vscode.workspace.fs.writeFile(cacheLog, Buffer.from(JSON.stringify(cacheLogOrder))); - - return results; - } -} - -export function toReviewUri( - uri: vscode.Uri, - filePath: string | undefined, - ref: string | undefined, - commit: string, - isOutdated: boolean, - options: GitUriOptions, - rootUri: vscode.Uri, -): vscode.Uri { - const params: ReviewUriParams = { - path: filePath ? filePath : uri.path, - ref, - commit: commit, - base: options.base, - isOutdated, - rootPath: rootUri.path, - }; - - let path = uri.path; - - if (options.replaceFileExtension) { - path = `${path}.git`; - } - - return uri.with({ - scheme: Schemes.Review, - path, - query: JSON.stringify(params), - }); -} - -export interface FileChangeNodeUriParams { - prNumber: number; - fileName: string; - previousFileName?: string; - status?: GitChangeType; -} - -export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: string, status: GitChangeType, previousFileName?: string) { - const params: FileChangeNodeUriParams = { - prNumber, - fileName, - status, - previousFileName - }; - - return uri.with({ - scheme: Schemes.FileChange, - query: JSON.stringify(params), - }); -} - -export function fromFileChangeNodeUri(uri: vscode.Uri): FileChangeNodeUriParams | undefined { - try { - return uri.query ? JSON.parse(uri.query) as FileChangeNodeUriParams : undefined; - } catch (e) { } -} - -export function toPRUri( - uri: vscode.Uri, - pullRequestModel: PullRequestModel, - baseCommit: string, - headCommit: string, - fileName: string, - base: boolean, - status: GitChangeType, - previousFileName?: string -): vscode.Uri { - const params: PRUriParams = { - baseCommit: baseCommit, - headCommit: headCommit, - isBase: base, - fileName: fileName, - prNumber: pullRequestModel.number, - status: status, - remoteName: pullRequestModel.githubRepository.remote.remoteName, - previousFileName - }; - - const path = uri.path; - - return uri.with({ - scheme: Schemes.Pr, - path, - query: JSON.stringify(params), - }); -} - -export function createPRNodeIdentifier(pullRequest: PullRequestModel | { remote: string, prNumber: number } | string) { - let identifier: string; - if (pullRequest instanceof PullRequestModel) { - identifier = `${pullRequest.remote.url}:${pullRequest.number}`; - } else if (typeof pullRequest === 'string') { - identifier = pullRequest; - } else { - identifier = `${pullRequest.remote}:${pullRequest.prNumber}`; - } - return identifier; -} - -export function createPRNodeUri( - pullRequest: PullRequestModel | { remote: string, prNumber: number } | string -): vscode.Uri { - const identifier = createPRNodeIdentifier(pullRequest); - const params: PRNodeUriParams = { - prIdentifier: identifier, - }; - - const uri = vscode.Uri.parse(`PRNode:${identifier}`); - - return uri.with({ - scheme: Schemes.PRNode, - query: JSON.stringify(params) - }); -} - -export enum Schemes { - File = 'file', - Review = 'review', - Pr = 'pr', - PRNode = 'prnode', - FileChange = 'filechange', - GithubPr = 'githubpr', - GitPr = 'gitpr', - VscodeVfs = 'vscode-vfs', // Remote Repository - Comment = 'comment' // Comments from the VS Code comment widget -} - -export function resolvePath(from: vscode.Uri, to: string) { - if (from.scheme === Schemes.File) { - return pathUtils.resolve(from.fsPath, to); - } else { - return pathUtils.posix.resolve(from.path, to); - } -} - -class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { - public handleUri(uri: vscode.Uri) { - this.fire(uri); - } -} - -export const handler = new UriEventHandler(); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +'use strict'; + +import { Buffer } from 'buffer'; +import * as pathUtils from 'path'; +import fetch from 'cross-fetch'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { IAccount, ITeam, reviewerId } from '../github/interface'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { GitChangeType } from './file'; +import Logger from './logger'; +import { TemporaryState } from './temporaryState'; + +export interface ReviewUriParams { + path: string; + ref?: string; + commit?: string; + base: boolean; + isOutdated: boolean; + rootPath: string; +} + +export function fromReviewUri(query: string): ReviewUriParams { + return JSON.parse(query); +} + +export interface PRUriParams { + baseCommit: string; + headCommit: string; + isBase: boolean; + fileName: string; + prNumber: number; + status: GitChangeType; + remoteName: string; + previousFileName?: string; +} + +export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { + try { + return JSON.parse(uri.query) as PRUriParams; + } catch (e) { } +} + +export interface PRNodeUriParams { + prIdentifier: string +} + +export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { + try { + return JSON.parse(uri.query) as PRNodeUriParams; + } catch (e) { } +} + +export interface GitHubUriParams { + fileName: string; + branch: string; + isEmpty?: boolean; +} +export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined { + try { + return JSON.parse(uri.query) as GitHubUriParams; + } catch (e) { } +} + +export interface GitUriOptions { + replaceFileExtension?: boolean; + submoduleOf?: string; + base: boolean; +} + +const ImageMimetypes = ['image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/tiff', 'image/bmp']; +// Known media types that VS Code can handle: https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/base/common/mime.ts#L33-L84 +export const KnownMediaExtensions = [ + '.aac', + '.avi', + '.bmp', + '.flv', + '.gif', + '.ico', + '.jpe', + '.jpeg', + '.jpg', + '.m1v', + '.m2a', + '.m2v', + '.m3a', + '.mid', + '.midi', + '.mk3d', + '.mks', + '.mkv', + '.mov', + '.movie', + '.mp2', + '.mp2a', + '.mp3', + '.mp4', + '.mp4a', + '.mp4v', + '.mpe', + '.mpeg', + '.mpg', + '.mpg4', + '.mpga', + '.oga', + '.ogg', + '.opus', + '.ogv', + '.png', + '.psd', + '.qt', + '.spx', + '.svg', + '.tga', + '.tif', + '.tiff', + '.wav', + '.webm', + '.webp', + '.wma', + '.wmv', + '.woff' +]; + +// a 1x1 pixel transparent gif, from http://png-pixel.com/ +export const EMPTY_IMAGE_URI = vscode.Uri.parse( + `data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`, +); + +export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): Promise { + try { + const { commit, baseCommit, headCommit, isBase, path }: { commit: string, baseCommit: string, headCommit: string, isBase: string, path: string } = JSON.parse(uri.query); + const ext = pathUtils.extname(path); + if (!KnownMediaExtensions.includes(ext)) { + return; + } + const ref = uri.scheme === Schemes.Review ? commit : isBase ? baseCommit : headCommit; + const { object } = await repository.getObjectDetails(ref, uri.fsPath); + const { mimetype } = await repository.detectObjectType(object); + + if (mimetype === 'text/plain') { + return; + } + + if (ImageMimetypes.indexOf(mimetype) > -1) { + const contents = await repository.buffer(ref, uri.fsPath); + return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents); + } + } catch (err) { + return; + } +} + +export namespace DataUri { + const iconsFolder = 'userIcons'; + + function iconFilename(user: IAccount | ITeam): string { + return `${reviewerId(user)}.jpg`; + } + + function cacheLocation(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(context.globalStorageUri, iconsFolder); + } + + function fileCacheUri(context: vscode.ExtensionContext, user: IAccount | ITeam): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), iconFilename(user)); + } + + function cacheLogUri(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), 'cache.log'); + } + + async function writeAvatarToCache(context: vscode.ExtensionContext, user: IAccount | ITeam, contents: Uint8Array): Promise { + await vscode.workspace.fs.createDirectory(cacheLocation(context)); + const file = fileCacheUri(context, user); + await vscode.workspace.fs.writeFile(file, contents); + return file; + } + + async function readAvatarFromCache(context: vscode.ExtensionContext, user: IAccount | ITeam): Promise { + try { + const file = fileCacheUri(context, user); + return vscode.workspace.fs.readFile(file); + } catch (e) { + return; + } + } + + export function asImageDataURI(contents: Buffer): vscode.Uri { + return vscode.Uri.parse( + `data:image/svg+xml;size:${contents.byteLength};base64,${contents.toString('base64')}` + ); + } + + export async function avatarCirclesAsImageDataUris(context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean): Promise<(vscode.Uri | undefined)[]> { + let cacheLogOrder: string[]; + const cacheLog = cacheLogUri(context); + try { + const log = await vscode.workspace.fs.readFile(cacheLog); + cacheLogOrder = JSON.parse(log.toString()); + } catch (e) { + cacheLogOrder = []; + } + const startingCacheSize = cacheLogOrder.length; + + const results = await Promise.all(users.map(async (user) => { + + const imageSourceUrl = user.avatarUrl; + if (imageSourceUrl === undefined) { + return undefined; + } + let innerImageContents: Buffer | undefined; + let cacheMiss: boolean = false; + try { + const fileContents = await readAvatarFromCache(context, user); + if (!fileContents) { + throw new Error('Temporary state not initialized'); + } + innerImageContents = Buffer.from(fileContents); + } catch (e) { + if (localOnly) { + return; + } + cacheMiss = true; + const doFetch = async () => { + const response = await fetch(imageSourceUrl.toString()); + const buffer = await response.arrayBuffer(); + await writeAvatarToCache(context, user, new Uint8Array(buffer)); + innerImageContents = Buffer.from(buffer); + }; + try { + await doFetch(); + } catch (e) { + // We retry once. + await doFetch(); + } + } + if (!innerImageContents) { + return undefined; + } + if (cacheMiss) { + const icon = iconFilename(user); + cacheLogOrder.push(icon); + } + const innerImageEncoded = `data:image/jpeg;size:${innerImageContents.byteLength};base64,${innerImageContents.toString('base64')}`; + const contentsString = ` + + `; + const contents = Buffer.from(contentsString); + const finalDataUri = asImageDataURI(contents); + return finalDataUri; + })); + + const maxCacheSize = Math.max(users.length, 200); + if (cacheLogOrder.length > startingCacheSize && startingCacheSize > 0 && cacheLogOrder.length > maxCacheSize) { + // The cache is getting big, we should clean it up. + const toDelete = cacheLogOrder.splice(0, 50); + await Promise.all(toDelete.map(async (id) => { + try { + await vscode.workspace.fs.delete(vscode.Uri.joinPath(cacheLocation(context), id)); + } catch (e) { + Logger.error(`Failed to delete avatar from cache: ${e}`); + } + })); + } + + await vscode.workspace.fs.writeFile(cacheLog, Buffer.from(JSON.stringify(cacheLogOrder))); + + return results; + } +} + +export function toReviewUri( + uri: vscode.Uri, + filePath: string | undefined, + ref: string | undefined, + commit: string, + isOutdated: boolean, + options: GitUriOptions, + rootUri: vscode.Uri, +): vscode.Uri { + const params: ReviewUriParams = { + path: filePath ? filePath : uri.path, + ref, + commit: commit, + base: options.base, + isOutdated, + rootPath: rootUri.path, + }; + + let path = uri.path; + + if (options.replaceFileExtension) { + path = `${path}.git`; + } + + return uri.with({ + scheme: Schemes.Review, + path, + query: JSON.stringify(params), + }); +} + +export interface FileChangeNodeUriParams { + prNumber: number; + fileName: string; + previousFileName?: string; + status?: GitChangeType; +} + +export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: string, status: GitChangeType, previousFileName?: string) { + const params: FileChangeNodeUriParams = { + prNumber, + fileName, + status, + previousFileName + }; + + return uri.with({ + scheme: Schemes.FileChange, + query: JSON.stringify(params), + }); +} + +export function fromFileChangeNodeUri(uri: vscode.Uri): FileChangeNodeUriParams | undefined { + try { + return uri.query ? JSON.parse(uri.query) as FileChangeNodeUriParams : undefined; + } catch (e) { } +} + +export function toPRUri( + uri: vscode.Uri, + pullRequestModel: PullRequestModel, + baseCommit: string, + headCommit: string, + fileName: string, + base: boolean, + status: GitChangeType, + previousFileName?: string +): vscode.Uri { + const params: PRUriParams = { + baseCommit: baseCommit, + headCommit: headCommit, + isBase: base, + fileName: fileName, + prNumber: pullRequestModel.number, + status: status, + remoteName: pullRequestModel.githubRepository.remote.remoteName, + previousFileName + }; + + const path = uri.path; + + return uri.with({ + scheme: Schemes.Pr, + path, + query: JSON.stringify(params), + }); +} + +export function createPRNodeIdentifier(pullRequest: PullRequestModel | { remote: string, prNumber: number } | string) { + let identifier: string; + if (pullRequest instanceof PullRequestModel) { + identifier = `${pullRequest.remote.url}:${pullRequest.number}`; + } else if (typeof pullRequest === 'string') { + identifier = pullRequest; + } else { + identifier = `${pullRequest.remote}:${pullRequest.prNumber}`; + } + return identifier; +} + +export function createPRNodeUri( + pullRequest: PullRequestModel | { remote: string, prNumber: number } | string +): vscode.Uri { + const identifier = createPRNodeIdentifier(pullRequest); + const params: PRNodeUriParams = { + prIdentifier: identifier, + }; + + const uri = vscode.Uri.parse(`PRNode:${identifier}`); + + return uri.with({ + scheme: Schemes.PRNode, + query: JSON.stringify(params) + }); +} + +export enum Schemes { + File = 'file', + Review = 'review', + Pr = 'pr', + PRNode = 'prnode', + FileChange = 'filechange', + GithubPr = 'githubpr', + GitPr = 'gitpr', + VscodeVfs = 'vscode-vfs', // Remote Repository + Comment = 'comment' // Comments from the VS Code comment widget +} + +export function resolvePath(from: vscode.Uri, to: string) { + if (from.scheme === Schemes.File) { + return pathUtils.resolve(from.fsPath, to); + } else { + return pathUtils.posix.resolve(from.path, to); + } +} + +class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { + public handleUri(uri: vscode.Uri) { + this.fire(uri); + } +} + +export const handler = new UriEventHandler(); diff --git a/src/common/user.ts b/src/common/user.ts index 71b3d7f5a7..b9a986e667 100644 --- a/src/common/user.ts +++ b/src/common/user.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + // https://jsdoc.app/index.html export const JSDOC_NON_USERS = ['abstract', 'virtual', 'access', 'alias', 'async', 'augments', 'extends', 'author', 'borrows', 'callback', 'class', 'constructor', 'classdesc', 'constant', 'const', 'constructs', 'copyright', 'default', 'defaultvalue', 'deprecated', 'description', 'desc', 'enum', 'event', 'example', 'exports', 'external', 'host', 'file', 'fileoverview', 'overview', 'fires', 'emits', 'function', 'func', 'method', 'generator', 'global', 'hideconstructor', 'ignore', 'implements', 'inheritdoc', 'inner', 'instance', 'interface', 'kind', 'lends', 'license', 'listens', 'member', 'var', 'memberof', 'mixes', 'mixin', 'module', 'name', 'namespace', 'override', 'package', 'param', 'arg', 'argument', 'private', 'property', 'prop', 'protected', 'public', 'readonly', 'requires', 'returns', 'return', 'see', 'since', 'static', 'summary', 'this', 'throws', 'exception', 'todo', 'tutorial', 'type', 'typedef', 'variation', 'version', 'yields', 'yield', 'link']; // https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md -export const PHPDOC_NON_USERS = ['api', 'author', 'copyright', 'deprecated', 'generated', 'internal', 'link', 'method', 'package', 'param', 'property', 'return', 'see', 'since', 'throws', 'todo', 'uses', 'var', 'version']; \ No newline at end of file +export const PHPDOC_NON_USERS = ['api', 'author', 'copyright', 'deprecated', 'generated', 'internal', 'link', 'method', 'package', 'param', 'property', 'return', 'see', 'since', 'throws', 'todo', 'uses', 'var', 'version']; diff --git a/src/common/utils.ts b/src/common/utils.ts index f836c4c91b..ac1c147d3e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,999 +1,1000 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; -import { sep } from 'path'; -import dayjs from 'dayjs'; -import * as relativeTime from 'dayjs/plugin/relativeTime'; -import * as updateLocale from 'dayjs/plugin/updateLocale'; -import type { Disposable, Event, ExtensionContext, Uri } from 'vscode'; -// TODO: localization for webview needed - -dayjs.extend(relativeTime.default, { - thresholds: [ - { l: 's', r: 44, d: 'second' }, - { l: 'm', r: 89 }, - { l: 'mm', r: 44, d: 'minute' }, - { l: 'h', r: 89 }, - { l: 'hh', r: 21, d: 'hour' }, - { l: 'd', r: 35 }, - { l: 'dd', r: 6, d: 'day' }, - { l: 'w', r: 7 }, - { l: 'ww', r: 3, d: 'week' }, - { l: 'M', r: 4 }, - { l: 'MM', r: 10, d: 'month' }, - { l: 'y', r: 17 }, - { l: 'yy', d: 'year' }, - ], -}); - -dayjs.extend(updateLocale.default); -dayjs.updateLocale('en', { - relativeTime: { - future: 'in %s', - past: '%s ago', - s: 'seconds', - m: 'a minute', - mm: '%d minutes', - h: 'an hour', - hh: '%d hours', - d: 'a day', - dd: '%d days', - w: 'a week', - ww: '%d weeks', - M: 'a month', - MM: '%d months', - y: 'a year', - yy: '%d years', - }, -}); - -export function uniqBy(arr: T[], fn: (el: T) => string): T[] { - const seen = Object.create(null); - - return arr.filter(el => { - const key = fn(el); - - if (seen[key]) { - return false; - } - - seen[key] = true; - return true; - }); -} - -export function dispose(disposables: T[]): T[] { - disposables.forEach(d => d.dispose()); - return []; -} - -export function toDisposable(d: () => void): Disposable { - return { dispose: d }; -} - -export function combinedDisposable(disposables: Disposable[]): Disposable { - return toDisposable(() => dispose(disposables)); -} - -export function anyEvent(...events: Event[]): Event { - return (listener, thisArgs = null, disposables?) => { - const result = combinedDisposable(events.map(event => event(i => listener.call(thisArgs, i)))); - - if (disposables) { - disposables.push(result); - } - - return result; - }; -} - -export function filterEvent(event: Event, filter: (e: T) => boolean): Event { - return (listener, thisArgs = null, disposables?: Disposable[]) => - event(e => filter(e) && listener.call(thisArgs, e), null, disposables); -} - -export function onceEvent(event: Event): Event { - return (listener, thisArgs = null, disposables?: Disposable[]) => { - const result = event( - e => { - result.dispose(); - return listener.call(thisArgs, e); - }, - null, - disposables, - ); - - return result; - }; -} - -function isWindowsPath(path: string): boolean { - return /^[a-zA-Z]:\\/.test(path); -} - -export function isDescendant(parent: string, descendant: string): boolean { - if (parent === descendant) { - return true; - } - - if (parent.charAt(parent.length - 1) !== sep) { - parent += sep; - } - - // Windows is case insensitive - if (isWindowsPath(parent)) { - parent = parent.toLowerCase(); - descendant = descendant.toLowerCase(); - } - - return descendant.startsWith(parent); -} - -export function groupBy(arr: T[], fn: (el: T) => string): { [key: string]: T[] } { - return arr.reduce((result, el) => { - const key = fn(el); - result[key] = [...(result[key] || []), el]; - return result; - }, Object.create(null)); -} - -export class UnreachableCaseError extends Error { - constructor(val: never) { - super(`Unreachable case: ${val}`); - } -} - -interface HookError extends Error { - errors: any; -} - -function isHookError(e: Error): e is HookError { - return !!(e as any).errors; -} - -function hasFieldErrors(e: any): e is Error & { errors: { value: string; field: string; code: string }[] } { - let areFieldErrors = true; - if (!!e.errors && Array.isArray(e.errors)) { - for (const error of e.errors) { - if (!error.field || !error.value || !error.code) { - areFieldErrors = false; - break; - } - } - } else { - areFieldErrors = false; - } - return areFieldErrors; -} - -export function formatError(e: HookError | any): string { - if (!(e instanceof Error)) { - if (typeof e === 'string') { - return e; - } - - if (e.gitErrorCode) { - // known git errors, we should display detailed git error messages. - return `${e.message}. Please check git output for more details`; - } else if (e.stderr) { - return `${e.stderr}. Please check git output for more details`; - } - return 'Error'; - } - - let errorMessage = e.message; - let furtherInfo: string | undefined; - if (e.message === 'Validation Failed' && hasFieldErrors(e)) { - furtherInfo = e.errors - .map(error => { - return `Value "${error.value}" cannot be set for field ${error.field} (code: ${error.code})`; - }) - .join(', '); - } else if (e.message.startsWith('Validation Failed:')) { - return e.message; - } else if (isHookError(e) && e.errors) { - return e.errors - .map((error: any) => { - if (typeof error === 'string') { - return error; - } else { - return error.message; - } - }) - .join(', '); - } - if (furtherInfo) { - errorMessage = `${errorMessage}: ${furtherInfo}`; - } - - return errorMessage; -} - -export interface PromiseAdapter { - (value: T, resolve: (value?: U | PromiseLike) => void, reject: (reason: any) => void): any; -} - -// Copied from https://github.com/microsoft/vscode/blob/cfd9d25826b5b5bc3b06677521660b4f1ba6639a/extensions/vscode-api-tests/src/utils.ts#L135-L136 -export async function asPromise(event: Event): Promise { - return new Promise((resolve) => { - const sub = event(e => { - sub.dispose(); - resolve(e); - }); - }); -} - -export async function promiseWithTimeout(promise: Promise, ms: number): Promise { - return Promise.race([promise, new Promise(resolve => { - setTimeout(() => resolve(undefined), ms); - })]); -} - -export function dateFromNow(date: Date | string): string { - const djs = dayjs(date); - - const now = Date.now(); - djs.diff(now, 'month'); - - if (djs.diff(now, 'month') < 1) { - return djs.fromNow(); - } else if (djs.diff(now, 'year') < 1) { - return `on ${djs.format('MMM D')}`; - } - return `on ${djs.format('MMM D, YYYY')}`; -} - - -export function gitHubLabelColor(hexColor: string, isDark: boolean, markDown: boolean = false): { textColor: string, backgroundColor: string, borderColor: string } { - if (hexColor.startsWith('#')) { - hexColor = hexColor.substring(1); - } - const rgbColor = hexToRgb(hexColor); - - if (isDark) { - const hslColor = rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b); - - const lightnessThreshold = 0.6; - const backgroundAlpha = 0.18; - const borderAlpha = 0.3; - - const perceivedLightness = (rgbColor.r * 0.2126 + rgbColor.g * 0.7152 + rgbColor.b * 0.0722) / 255; - const lightnessSwitch = Math.max(0, Math.min((perceivedLightness - lightnessThreshold) * -1000, 1)); - - const lightenBy = (lightnessThreshold - perceivedLightness) * 100 * lightnessSwitch; - const rgbBorder = hexToRgb(hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)); - - const textColor = `#${hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)}`; - const backgroundColor = !markDown ? - `rgba(${rgbColor.r},${rgbColor.g},${rgbColor.b},${backgroundAlpha})` : - `#${rgbToHex({ ...rgbColor, a: backgroundAlpha })}`; - const borderColor = !markDown ? - `rgba(${rgbBorder.r},${rgbBorder.g},${rgbBorder.b},${borderAlpha})` : - `#${rgbToHex({ ...rgbBorder, a: borderAlpha })}`; - - return { textColor: textColor, backgroundColor: backgroundColor, borderColor: borderColor }; - } - else { - return { textColor: `#${contrastColor(rgbColor)}`, backgroundColor: `#${hexColor}`, borderColor: `#${hexColor}` }; - } -} - -const rgbToHex = (color: { r: number, g: number, b: number, a?: number }) => { - const colors = [color.r, color.g, color.b]; - if (color.a) { - colors.push(Math.floor(color.a * 255)); - } - return colors.map((digit) => { - return digit.toString(16).padStart(2, '0'); - }).join(''); -}; - -function hexToRgb(color: string) { - const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); - - if (result) { - return { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - }; - } - return { - r: 0, - g: 0, - b: 0, - }; -} - -function rgbToHsl(r: number, g: number, b: number) { - // Source: https://css-tricks.com/converting-color-spaces-in-javascript/ - // Make r, g, and b fractions of 1 - r /= 255; - g /= 255; - b /= 255; - - // Find greatest and smallest channel values - let cmin = Math.min(r, g, b), - cmax = Math.max(r, g, b), - delta = cmax - cmin, - h = 0, - s = 0, - l = 0; - - // Calculate hue - // No difference - if (delta == 0) - h = 0; - // Red is max - else if (cmax == r) - h = ((g - b) / delta) % 6; - // Green is max - else if (cmax == g) - h = (b - r) / delta + 2; - // Blue is max - else - h = (r - g) / delta + 4; - - h = Math.round(h * 60); - - // Make negative hues positive behind 360 deg - if (h < 0) - h += 360; - - // Calculate lightness - l = (cmax + cmin) / 2; - - // Calculate saturation - s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); - - // Multiply l and s by 100 - s = +(s * 100).toFixed(1); - l = +(l * 100).toFixed(1); - - return { h: h, s: s, l: l }; -} - -function hslToHex(h: number, s: number, l: number): string { - // source https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/ - const hDecimal = l / 100; - const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100; - const f = (n: number) => { - const k = (n + h / 30) % 12; - const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - - // Convert to Hex and prefix with "0" if required - return Math.round(255 * color) - .toString(16) - .padStart(2, '0'); - }; - return `${f(0)}${f(8)}${f(4)}`; -} - -function contrastColor(rgbColor: { r: number, g: number, b: number }) { - // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color - const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; - return luminance > 0.5 ? '000000' : 'ffffff'; -} - -export interface Predicate { - (input: T): boolean; -} - -export const enum CharCode { - Period = 46, - /** - * The `/` character. - */ - Slash = 47, - - A = 65, - Z = 90, - - Backslash = 92, - - a = 97, - z = 122, -} - -export function compare(a: string, b: string): number { - if (a < b) { - return -1; - } else if (a > b) { - return 1; - } - return 0; -} - -export function compareSubstring( - a: string, - b: string, - aStart: number = 0, - aEnd: number = a.length, - bStart: number = 0, - bEnd: number = b.length, -): number { - for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { - const codeA = a.charCodeAt(aStart); - const codeB = b.charCodeAt(bStart); - if (codeA < codeB) { - return -1; - } else if (codeA > codeB) { - return 1; - } - } - const aLen = aEnd - aStart; - const bLen = bEnd - bStart; - if (aLen < bLen) { - return -1; - } else if (aLen > bLen) { - return 1; - } - return 0; -} - -export function compareIgnoreCase(a: string, b: string): number { - return compareSubstringIgnoreCase(a, b, 0, a.length, 0, b.length); -} - -export function compareSubstringIgnoreCase( - a: string, - b: string, - aStart: number = 0, - aEnd: number = a.length, - bStart: number = 0, - bEnd: number = b.length, -): number { - for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { - let codeA = a.charCodeAt(aStart); - let codeB = b.charCodeAt(bStart); - - if (codeA === codeB) { - // equal - continue; - } - - const diff = codeA - codeB; - if (diff === 32 && isUpperAsciiLetter(codeB)) { - //codeB =[65-90] && codeA =[97-122] - continue; - } else if (diff === -32 && isUpperAsciiLetter(codeA)) { - //codeB =[97-122] && codeA =[65-90] - continue; - } - - if (isLowerAsciiLetter(codeA) && isLowerAsciiLetter(codeB)) { - // - return diff; - } else { - return compareSubstring(a.toLowerCase(), b.toLowerCase(), aStart, aEnd, bStart, bEnd); - } - } - - const aLen = aEnd - aStart; - const bLen = bEnd - bStart; - - if (aLen < bLen) { - return -1; - } else if (aLen > bLen) { - return 1; - } - - return 0; -} - -export function isLowerAsciiLetter(code: number): boolean { - return code >= CharCode.a && code <= CharCode.z; -} - -export function isUpperAsciiLetter(code: number): boolean { - return code >= CharCode.A && code <= CharCode.Z; -} - -export interface IKeyIterator { - reset(key: K): this; - next(): this; - - hasNext(): boolean; - cmp(a: string): number; - value(): string; -} - -export class StringIterator implements IKeyIterator { - private _value: string = ''; - private _pos: number = 0; - - reset(key: string): this { - this._value = key; - this._pos = 0; - return this; - } - - next(): this { - this._pos += 1; - return this; - } - - hasNext(): boolean { - return this._pos < this._value.length - 1; - } - - cmp(a: string): number { - const aCode = a.charCodeAt(0); - const thisCode = this._value.charCodeAt(this._pos); - return aCode - thisCode; - } - - value(): string { - return this._value[this._pos]; - } -} - -export class ConfigKeysIterator implements IKeyIterator { - private _value!: string; - private _from!: number; - private _to!: number; - - constructor(private readonly _caseSensitive: boolean = true) { } - - reset(key: string): this { - this._value = key; - this._from = 0; - this._to = 0; - return this.next(); - } - - hasNext(): boolean { - return this._to < this._value.length; - } - - next(): this { - // this._data = key.split(/[\\/]/).filter(s => !!s); - this._from = this._to; - let justSeps = true; - for (; this._to < this._value.length; this._to++) { - const ch = this._value.charCodeAt(this._to); - if (ch === CharCode.Period) { - if (justSeps) { - this._from++; - } else { - break; - } - } else { - justSeps = false; - } - } - return this; - } - - cmp(a: string): number { - return this._caseSensitive - ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) - : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); - } - - value(): string { - return this._value.substring(this._from, this._to); - } -} - -export class PathIterator implements IKeyIterator { - private _value!: string; - private _from!: number; - private _to!: number; - - constructor(private readonly _splitOnBackslash: boolean = true, private readonly _caseSensitive: boolean = true) { } - - reset(key: string): this { - this._value = key.replace(/\\$|\/$/, ''); - this._from = 0; - this._to = 0; - return this.next(); - } - - hasNext(): boolean { - return this._to < this._value.length; - } - - next(): this { - // this._data = key.split(/[\\/]/).filter(s => !!s); - this._from = this._to; - let justSeps = true; - for (; this._to < this._value.length; this._to++) { - const ch = this._value.charCodeAt(this._to); - if (ch === CharCode.Slash || (this._splitOnBackslash && ch === CharCode.Backslash)) { - if (justSeps) { - this._from++; - } else { - break; - } - } else { - justSeps = false; - } - } - return this; - } - - cmp(a: string): number { - return this._caseSensitive - ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) - : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); - } - - value(): string { - return this._value.substring(this._from, this._to); - } -} - -const enum UriIteratorState { - Scheme = 1, - Authority = 2, - Path = 3, - Query = 4, - Fragment = 5, -} - -export class UriIterator implements IKeyIterator { - private _pathIterator!: PathIterator; - private _value!: Uri; - private _states: UriIteratorState[] = []; - private _stateIdx: number = 0; - - constructor(private readonly _ignorePathCasing: (uri: Uri) => boolean) { } - - reset(key: Uri): this { - this._value = key; - this._states = []; - if (this._value.scheme) { - this._states.push(UriIteratorState.Scheme); - } - if (this._value.authority) { - this._states.push(UriIteratorState.Authority); - } - if (this._value.path) { - this._pathIterator = new PathIterator(false, !this._ignorePathCasing(key)); - this._pathIterator.reset(key.path); - if (this._pathIterator.value()) { - this._states.push(UriIteratorState.Path); - } - } - if (this._value.query) { - this._states.push(UriIteratorState.Query); - } - if (this._value.fragment) { - this._states.push(UriIteratorState.Fragment); - } - this._stateIdx = 0; - return this; - } - - next(): this { - if (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) { - this._pathIterator.next(); - } else { - this._stateIdx += 1; - } - return this; - } - - hasNext(): boolean { - return ( - (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) || - this._stateIdx < this._states.length - 1 - ); - } - - cmp(a: string): number { - if (this._states[this._stateIdx] === UriIteratorState.Scheme) { - return compareIgnoreCase(a, this._value.scheme); - } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { - return compareIgnoreCase(a, this._value.authority); - } else if (this._states[this._stateIdx] === UriIteratorState.Path) { - return this._pathIterator.cmp(a); - } else if (this._states[this._stateIdx] === UriIteratorState.Query) { - return compare(a, this._value.query); - } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { - return compare(a, this._value.fragment); - } - throw new Error(); - } - - value(): string { - if (this._states[this._stateIdx] === UriIteratorState.Scheme) { - return this._value.scheme; - } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { - return this._value.authority; - } else if (this._states[this._stateIdx] === UriIteratorState.Path) { - return this._pathIterator.value(); - } else if (this._states[this._stateIdx] === UriIteratorState.Query) { - return this._value.query; - } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { - return this._value.fragment; - } - throw new Error(); - } -} - -export function isPreRelease(context: ExtensionContext): boolean { - const uri = context.extensionUri; - const path = uri.path; - const lastIndexOfDot = path.lastIndexOf('.'); - if (lastIndexOfDot === -1) { - return false; - } - const patchVersion = path.substr(lastIndexOfDot + 1); - // The patch version of release versions should never be more than 1 digit since it is only used for recovery releases. - // The patch version of pre-release is the date + time. - return patchVersion.length > 1; -} - -class TernarySearchTreeNode { - segment!: string; - value: V | undefined; - key!: K; - left: TernarySearchTreeNode | undefined; - mid: TernarySearchTreeNode | undefined; - right: TernarySearchTreeNode | undefined; - - isEmpty(): boolean { - return !this.left && !this.mid && !this.right && !this.value; - } -} - -export class TernarySearchTree { - static forUris(ignorePathCasing: (key: Uri) => boolean = () => false): TernarySearchTree { - return new TernarySearchTree(new UriIterator(ignorePathCasing)); - } - - static forPaths(): TernarySearchTree { - return new TernarySearchTree(new PathIterator()); - } - - static forStrings(): TernarySearchTree { - return new TernarySearchTree(new StringIterator()); - } - - static forConfigKeys(): TernarySearchTree { - return new TernarySearchTree(new ConfigKeysIterator()); - } - - private _iter: IKeyIterator; - private _root: TernarySearchTreeNode | undefined; - - constructor(segments: IKeyIterator) { - this._iter = segments; - } - - clear(): void { - this._root = undefined; - } - - set(key: K, element: V): V | undefined { - const iter = this._iter.reset(key); - let node: TernarySearchTreeNode; - - if (!this._root) { - this._root = new TernarySearchTreeNode(); - this._root.segment = iter.value(); - } - - node = this._root; - while (true) { - const val = iter.cmp(node.segment); - if (val > 0) { - // left - if (!node.left) { - node.left = new TernarySearchTreeNode(); - node.left.segment = iter.value(); - } - node = node.left; - } else if (val < 0) { - // right - if (!node.right) { - node.right = new TernarySearchTreeNode(); - node.right.segment = iter.value(); - } - node = node.right; - } else if (iter.hasNext()) { - // mid - iter.next(); - if (!node.mid) { - node.mid = new TernarySearchTreeNode(); - node.mid.segment = iter.value(); - } - node = node.mid; - } else { - break; - } - } - const oldElement = node.value; - node.value = element; - node.key = key; - return oldElement; - } - - get(key: K): V | undefined { - return this._getNode(key)?.value; - } - - private _getNode(key: K) { - const iter = this._iter.reset(key); - let node = this._root; - while (node) { - const val = iter.cmp(node.segment); - if (val > 0) { - // left - node = node.left; - } else if (val < 0) { - // right - node = node.right; - } else if (iter.hasNext()) { - // mid - iter.next(); - node = node.mid; - } else { - break; - } - } - return node; - } - - has(key: K): boolean { - const node = this._getNode(key); - return !(node?.value === undefined && node?.mid === undefined); - } - - delete(key: K): void { - return this._delete(key, false); - } - - deleteSuperstr(key: K): void { - return this._delete(key, true); - } - - private _delete(key: K, superStr: boolean): void { - const iter = this._iter.reset(key); - const stack: [-1 | 0 | 1, TernarySearchTreeNode][] = []; - let node = this._root; - - // find and unset node - while (node) { - const val = iter.cmp(node.segment); - if (val > 0) { - // left - stack.push([1, node]); - node = node.left; - } else if (val < 0) { - // right - stack.push([-1, node]); - node = node.right; - } else if (iter.hasNext()) { - // mid - iter.next(); - stack.push([0, node]); - node = node.mid; - } else { - if (superStr) { - // remove children - node.left = undefined; - node.mid = undefined; - node.right = undefined; - } else { - // remove element - node.value = undefined; - } - - // clean up empty nodes - while (stack.length > 0 && node.isEmpty()) { - let [dir, parent] = stack.pop()!; - switch (dir) { - case 1: - parent.left = undefined; - break; - case 0: - parent.mid = undefined; - break; - case -1: - parent.right = undefined; - break; - } - node = parent; - } - break; - } - } - } - - findSubstr(key: K): V | undefined { - const iter = this._iter.reset(key); - let node = this._root; - let candidate: V | undefined = undefined; - while (node) { - const val = iter.cmp(node.segment); - if (val > 0) { - // left - node = node.left; - } else if (val < 0) { - // right - node = node.right; - } else if (iter.hasNext()) { - // mid - iter.next(); - candidate = node.value || candidate; - node = node.mid; - } else { - break; - } - } - return (node && node.value) || candidate; - } - - findSuperstr(key: K): IterableIterator<[K, V]> | undefined { - const iter = this._iter.reset(key); - let node = this._root; - while (node) { - const val = iter.cmp(node.segment); - if (val > 0) { - // left - node = node.left; - } else if (val < 0) { - // right - node = node.right; - } else if (iter.hasNext()) { - // mid - iter.next(); - node = node.mid; - } else { - // collect - if (!node.mid) { - return undefined; - } else { - return this._entries(node.mid); - } - } - } - return undefined; - } - - forEach(callback: (value: V, index: K) => any): void { - for (const [key, value] of this) { - callback(value, key); - } - } - - *[Symbol.iterator](): IterableIterator<[K, V]> { - yield* this._entries(this._root); - } - - private *_entries(node: TernarySearchTreeNode | undefined): IterableIterator<[K, V]> { - if (node) { - // left - yield* this._entries(node.left); - - // node - if (node.value) { - // callback(node.value, this._iter.join(parts)); - yield [node.key, node.value]; - } - // mid - yield* this._entries(node.mid); - - // right - yield* this._entries(node.right); - } - } -} - -export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (substring: string, ...args: any[]) => Promise): Promise { - const promises: Promise[] = []; - str.replace(regex, (match, ...args) => { - const promise = asyncFn(match, ...args); - promises.push(promise); - return ''; - }); - const data = await Promise.all(promises); - let offset = 0; - return str.replace(regex, () => data[offset++]); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +'use strict'; +import { sep } from 'path'; +import dayjs from 'dayjs'; +import * as relativeTime from 'dayjs/plugin/relativeTime'; +import * as updateLocale from 'dayjs/plugin/updateLocale'; +import type { Disposable, Event, ExtensionContext, Uri } from 'vscode'; +// TODO: localization for webview needed + +dayjs.extend(relativeTime.default, { + thresholds: [ + { l: 's', r: 44, d: 'second' }, + { l: 'm', r: 89 }, + { l: 'mm', r: 44, d: 'minute' }, + { l: 'h', r: 89 }, + { l: 'hh', r: 21, d: 'hour' }, + { l: 'd', r: 35 }, + { l: 'dd', r: 6, d: 'day' }, + { l: 'w', r: 7 }, + { l: 'ww', r: 3, d: 'week' }, + { l: 'M', r: 4 }, + { l: 'MM', r: 10, d: 'month' }, + { l: 'y', r: 17 }, + { l: 'yy', d: 'year' }, + ], +}); + +dayjs.extend(updateLocale.default); +dayjs.updateLocale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: 'seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + w: 'a week', + ww: '%d weeks', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', + }, +}); + +export function uniqBy(arr: T[], fn: (el: T) => string): T[] { + const seen = Object.create(null); + + return arr.filter(el => { + const key = fn(el); + + if (seen[key]) { + return false; + } + + seen[key] = true; + return true; + }); +} + +export function dispose(disposables: T[]): T[] { + disposables.forEach(d => d.dispose()); + return []; +} + +export function toDisposable(d: () => void): Disposable { + return { dispose: d }; +} + +export function combinedDisposable(disposables: Disposable[]): Disposable { + return toDisposable(() => dispose(disposables)); +} + +export function anyEvent(...events: Event[]): Event { + return (listener, thisArgs = null, disposables?) => { + const result = combinedDisposable(events.map(event => event(i => listener.call(thisArgs, i)))); + + if (disposables) { + disposables.push(result); + } + + return result; + }; +} + +export function filterEvent(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArgs = null, disposables?: Disposable[]) => + event(e => filter(e) && listener.call(thisArgs, e), null, disposables); +} + +export function onceEvent(event: Event): Event { + return (listener, thisArgs = null, disposables?: Disposable[]) => { + const result = event( + e => { + result.dispose(); + return listener.call(thisArgs, e); + }, + null, + disposables, + ); + + return result; + }; +} + +function isWindowsPath(path: string): boolean { + return /^[a-zA-Z]:\\/.test(path); +} + +export function isDescendant(parent: string, descendant: string): boolean { + if (parent === descendant) { + return true; + } + + if (parent.charAt(parent.length - 1) !== sep) { + parent += sep; + } + + // Windows is case insensitive + if (isWindowsPath(parent)) { + parent = parent.toLowerCase(); + descendant = descendant.toLowerCase(); + } + + return descendant.startsWith(parent); +} + +export function groupBy(arr: T[], fn: (el: T) => string): { [key: string]: T[] } { + return arr.reduce((result, el) => { + const key = fn(el); + result[key] = [...(result[key] || []), el]; + return result; + }, Object.create(null)); +} + +export class UnreachableCaseError extends Error { + constructor(val: never) { + super(`Unreachable case: ${val}`); + } +} + +interface HookError extends Error { + errors: any; +} + +function isHookError(e: Error): e is HookError { + return !!(e as any).errors; +} + +function hasFieldErrors(e: any): e is Error & { errors: { value: string; field: string; code: string }[] } { + let areFieldErrors = true; + if (!!e.errors && Array.isArray(e.errors)) { + for (const error of e.errors) { + if (!error.field || !error.value || !error.code) { + areFieldErrors = false; + break; + } + } + } else { + areFieldErrors = false; + } + return areFieldErrors; +} + +export function formatError(e: HookError | any): string { + if (!(e instanceof Error)) { + if (typeof e === 'string') { + return e; + } + + if (e.gitErrorCode) { + // known git errors, we should display detailed git error messages. + return `${e.message}. Please check git output for more details`; + } else if (e.stderr) { + return `${e.stderr}. Please check git output for more details`; + } + return 'Error'; + } + + let errorMessage = e.message; + let furtherInfo: string | undefined; + if (e.message === 'Validation Failed' && hasFieldErrors(e)) { + furtherInfo = e.errors + .map(error => { + return `Value "${error.value}" cannot be set for field ${error.field} (code: ${error.code})`; + }) + .join(', '); + } else if (e.message.startsWith('Validation Failed:')) { + return e.message; + } else if (isHookError(e) && e.errors) { + return e.errors + .map((error: any) => { + if (typeof error === 'string') { + return error; + } else { + return error.message; + } + }) + .join(', '); + } + if (furtherInfo) { + errorMessage = `${errorMessage}: ${furtherInfo}`; + } + + return errorMessage; +} + +export interface PromiseAdapter { + (value: T, resolve: (value?: U | PromiseLike) => void, reject: (reason: any) => void): any; +} + +// Copied from https://github.com/microsoft/vscode/blob/cfd9d25826b5b5bc3b06677521660b4f1ba6639a/extensions/vscode-api-tests/src/utils.ts#L135-L136 +export async function asPromise(event: Event): Promise { + return new Promise((resolve) => { + const sub = event(e => { + sub.dispose(); + resolve(e); + }); + }); +} + +export async function promiseWithTimeout(promise: Promise, ms: number): Promise { + return Promise.race([promise, new Promise(resolve => { + setTimeout(() => resolve(undefined), ms); + })]); +} + +export function dateFromNow(date: Date | string): string { + const djs = dayjs(date); + + const now = Date.now(); + djs.diff(now, 'month'); + + if (djs.diff(now, 'month') < 1) { + return djs.fromNow(); + } else if (djs.diff(now, 'year') < 1) { + return `on ${djs.format('MMM D')}`; + } + return `on ${djs.format('MMM D, YYYY')}`; +} + + +export function gitHubLabelColor(hexColor: string, isDark: boolean, markDown: boolean = false): { textColor: string, backgroundColor: string, borderColor: string } { + if (hexColor.startsWith('#')) { + hexColor = hexColor.substring(1); + } + const rgbColor = hexToRgb(hexColor); + + if (isDark) { + const hslColor = rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b); + + const lightnessThreshold = 0.6; + const backgroundAlpha = 0.18; + const borderAlpha = 0.3; + + const perceivedLightness = (rgbColor.r * 0.2126 + rgbColor.g * 0.7152 + rgbColor.b * 0.0722) / 255; + const lightnessSwitch = Math.max(0, Math.min((perceivedLightness - lightnessThreshold) * -1000, 1)); + + const lightenBy = (lightnessThreshold - perceivedLightness) * 100 * lightnessSwitch; + const rgbBorder = hexToRgb(hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)); + + const textColor = `#${hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)}`; + const backgroundColor = !markDown ? + `rgba(${rgbColor.r},${rgbColor.g},${rgbColor.b},${backgroundAlpha})` : + `#${rgbToHex({ ...rgbColor, a: backgroundAlpha })}`; + const borderColor = !markDown ? + `rgba(${rgbBorder.r},${rgbBorder.g},${rgbBorder.b},${borderAlpha})` : + `#${rgbToHex({ ...rgbBorder, a: borderAlpha })}`; + + return { textColor: textColor, backgroundColor: backgroundColor, borderColor: borderColor }; + } + else { + return { textColor: `#${contrastColor(rgbColor)}`, backgroundColor: `#${hexColor}`, borderColor: `#${hexColor}` }; + } +} + +const rgbToHex = (color: { r: number, g: number, b: number, a?: number }) => { + const colors = [color.r, color.g, color.b]; + if (color.a) { + colors.push(Math.floor(color.a * 255)); + } + return colors.map((digit) => { + return digit.toString(16).padStart(2, '0'); + }).join(''); +}; + +function hexToRgb(color: string) { + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + + if (result) { + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; + } + return { + r: 0, + g: 0, + b: 0, + }; +} + +function rgbToHsl(r: number, g: number, b: number) { + // Source: https://css-tricks.com/converting-color-spaces-in-javascript/ + // Make r, g, and b fractions of 1 + r /= 255; + g /= 255; + b /= 255; + + // Find greatest and smallest channel values + let cmin = Math.min(r, g, b), + cmax = Math.max(r, g, b), + delta = cmax - cmin, + h = 0, + s = 0, + l = 0; + + // Calculate hue + // No difference + if (delta == 0) + h = 0; + // Red is max + else if (cmax == r) + h = ((g - b) / delta) % 6; + // Green is max + else if (cmax == g) + h = (b - r) / delta + 2; + // Blue is max + else + h = (r - g) / delta + 4; + + h = Math.round(h * 60); + + // Make negative hues positive behind 360 deg + if (h < 0) + h += 360; + + // Calculate lightness + l = (cmax + cmin) / 2; + + // Calculate saturation + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + // Multiply l and s by 100 + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h: h, s: s, l: l }; +} + +function hslToHex(h: number, s: number, l: number): string { + // source https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/ + const hDecimal = l / 100; + const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + + // Convert to Hex and prefix with "0" if required + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `${f(0)}${f(8)}${f(4)}`; +} + +function contrastColor(rgbColor: { r: number, g: number, b: number }) { + // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color + const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; + return luminance > 0.5 ? '000000' : 'ffffff'; +} + +export interface Predicate { + (input: T): boolean; +} + +export const enum CharCode { + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + A = 65, + Z = 90, + + Backslash = 92, + + a = 97, + z = 122, +} + +export function compare(a: string, b: string): number { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } + return 0; +} + +export function compareSubstring( + a: string, + b: string, + aStart: number = 0, + aEnd: number = a.length, + bStart: number = 0, + bEnd: number = b.length, +): number { + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + const codeA = a.charCodeAt(aStart); + const codeB = b.charCodeAt(bStart); + if (codeA < codeB) { + return -1; + } else if (codeA > codeB) { + return 1; + } + } + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + return 0; +} + +export function compareIgnoreCase(a: string, b: string): number { + return compareSubstringIgnoreCase(a, b, 0, a.length, 0, b.length); +} + +export function compareSubstringIgnoreCase( + a: string, + b: string, + aStart: number = 0, + aEnd: number = a.length, + bStart: number = 0, + bEnd: number = b.length, +): number { + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + let codeA = a.charCodeAt(aStart); + let codeB = b.charCodeAt(bStart); + + if (codeA === codeB) { + // equal + continue; + } + + const diff = codeA - codeB; + if (diff === 32 && isUpperAsciiLetter(codeB)) { + //codeB =[65-90] && codeA =[97-122] + continue; + } else if (diff === -32 && isUpperAsciiLetter(codeA)) { + //codeB =[97-122] && codeA =[65-90] + continue; + } + + if (isLowerAsciiLetter(codeA) && isLowerAsciiLetter(codeB)) { + // + return diff; + } else { + return compareSubstring(a.toLowerCase(), b.toLowerCase(), aStart, aEnd, bStart, bEnd); + } + } + + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + + return 0; +} + +export function isLowerAsciiLetter(code: number): boolean { + return code >= CharCode.a && code <= CharCode.z; +} + +export function isUpperAsciiLetter(code: number): boolean { + return code >= CharCode.A && code <= CharCode.Z; +} + +export interface IKeyIterator { + reset(key: K): this; + next(): this; + + hasNext(): boolean; + cmp(a: string): number; + value(): string; +} + +export class StringIterator implements IKeyIterator { + private _value: string = ''; + private _pos: number = 0; + + reset(key: string): this { + this._value = key; + this._pos = 0; + return this; + } + + next(): this { + this._pos += 1; + return this; + } + + hasNext(): boolean { + return this._pos < this._value.length - 1; + } + + cmp(a: string): number { + const aCode = a.charCodeAt(0); + const thisCode = this._value.charCodeAt(this._pos); + return aCode - thisCode; + } + + value(): string { + return this._value[this._pos]; + } +} + +export class ConfigKeysIterator implements IKeyIterator { + private _value!: string; + private _from!: number; + private _to!: number; + + constructor(private readonly _caseSensitive: boolean = true) { } + + reset(key: string): this { + this._value = key; + this._from = 0; + this._to = 0; + return this.next(); + } + + hasNext(): boolean { + return this._to < this._value.length; + } + + next(): this { + // this._data = key.split(/[\\/]/).filter(s => !!s); + this._from = this._to; + let justSeps = true; + for (; this._to < this._value.length; this._to++) { + const ch = this._value.charCodeAt(this._to); + if (ch === CharCode.Period) { + if (justSeps) { + this._from++; + } else { + break; + } + } else { + justSeps = false; + } + } + return this; + } + + cmp(a: string): number { + return this._caseSensitive + ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) + : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); + } + + value(): string { + return this._value.substring(this._from, this._to); + } +} + +export class PathIterator implements IKeyIterator { + private _value!: string; + private _from!: number; + private _to!: number; + + constructor(private readonly _splitOnBackslash: boolean = true, private readonly _caseSensitive: boolean = true) { } + + reset(key: string): this { + this._value = key.replace(/\\$|\/$/, ''); + this._from = 0; + this._to = 0; + return this.next(); + } + + hasNext(): boolean { + return this._to < this._value.length; + } + + next(): this { + // this._data = key.split(/[\\/]/).filter(s => !!s); + this._from = this._to; + let justSeps = true; + for (; this._to < this._value.length; this._to++) { + const ch = this._value.charCodeAt(this._to); + if (ch === CharCode.Slash || (this._splitOnBackslash && ch === CharCode.Backslash)) { + if (justSeps) { + this._from++; + } else { + break; + } + } else { + justSeps = false; + } + } + return this; + } + + cmp(a: string): number { + return this._caseSensitive + ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) + : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); + } + + value(): string { + return this._value.substring(this._from, this._to); + } +} + +const enum UriIteratorState { + Scheme = 1, + Authority = 2, + Path = 3, + Query = 4, + Fragment = 5, +} + +export class UriIterator implements IKeyIterator { + private _pathIterator!: PathIterator; + private _value!: Uri; + private _states: UriIteratorState[] = []; + private _stateIdx: number = 0; + + constructor(private readonly _ignorePathCasing: (uri: Uri) => boolean) { } + + reset(key: Uri): this { + this._value = key; + this._states = []; + if (this._value.scheme) { + this._states.push(UriIteratorState.Scheme); + } + if (this._value.authority) { + this._states.push(UriIteratorState.Authority); + } + if (this._value.path) { + this._pathIterator = new PathIterator(false, !this._ignorePathCasing(key)); + this._pathIterator.reset(key.path); + if (this._pathIterator.value()) { + this._states.push(UriIteratorState.Path); + } + } + if (this._value.query) { + this._states.push(UriIteratorState.Query); + } + if (this._value.fragment) { + this._states.push(UriIteratorState.Fragment); + } + this._stateIdx = 0; + return this; + } + + next(): this { + if (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) { + this._pathIterator.next(); + } else { + this._stateIdx += 1; + } + return this; + } + + hasNext(): boolean { + return ( + (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) || + this._stateIdx < this._states.length - 1 + ); + } + + cmp(a: string): number { + if (this._states[this._stateIdx] === UriIteratorState.Scheme) { + return compareIgnoreCase(a, this._value.scheme); + } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { + return compareIgnoreCase(a, this._value.authority); + } else if (this._states[this._stateIdx] === UriIteratorState.Path) { + return this._pathIterator.cmp(a); + } else if (this._states[this._stateIdx] === UriIteratorState.Query) { + return compare(a, this._value.query); + } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { + return compare(a, this._value.fragment); + } + throw new Error(); + } + + value(): string { + if (this._states[this._stateIdx] === UriIteratorState.Scheme) { + return this._value.scheme; + } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { + return this._value.authority; + } else if (this._states[this._stateIdx] === UriIteratorState.Path) { + return this._pathIterator.value(); + } else if (this._states[this._stateIdx] === UriIteratorState.Query) { + return this._value.query; + } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { + return this._value.fragment; + } + throw new Error(); + } +} + +export function isPreRelease(context: ExtensionContext): boolean { + const uri = context.extensionUri; + const path = uri.path; + const lastIndexOfDot = path.lastIndexOf('.'); + if (lastIndexOfDot === -1) { + return false; + } + const patchVersion = path.substr(lastIndexOfDot + 1); + // The patch version of release versions should never be more than 1 digit since it is only used for recovery releases. + // The patch version of pre-release is the date + time. + return patchVersion.length > 1; +} + +class TernarySearchTreeNode { + segment!: string; + value: V | undefined; + key!: K; + left: TernarySearchTreeNode | undefined; + mid: TernarySearchTreeNode | undefined; + right: TernarySearchTreeNode | undefined; + + isEmpty(): boolean { + return !this.left && !this.mid && !this.right && !this.value; + } +} + +export class TernarySearchTree { + static forUris(ignorePathCasing: (key: Uri) => boolean = () => false): TernarySearchTree { + return new TernarySearchTree(new UriIterator(ignorePathCasing)); + } + + static forPaths(): TernarySearchTree { + return new TernarySearchTree(new PathIterator()); + } + + static forStrings(): TernarySearchTree { + return new TernarySearchTree(new StringIterator()); + } + + static forConfigKeys(): TernarySearchTree { + return new TernarySearchTree(new ConfigKeysIterator()); + } + + private _iter: IKeyIterator; + private _root: TernarySearchTreeNode | undefined; + + constructor(segments: IKeyIterator) { + this._iter = segments; + } + + clear(): void { + this._root = undefined; + } + + set(key: K, element: V): V | undefined { + const iter = this._iter.reset(key); + let node: TernarySearchTreeNode; + + if (!this._root) { + this._root = new TernarySearchTreeNode(); + this._root.segment = iter.value(); + } + + node = this._root; + while (true) { + const val = iter.cmp(node.segment); + if (val > 0) { + // left + if (!node.left) { + node.left = new TernarySearchTreeNode(); + node.left.segment = iter.value(); + } + node = node.left; + } else if (val < 0) { + // right + if (!node.right) { + node.right = new TernarySearchTreeNode(); + node.right.segment = iter.value(); + } + node = node.right; + } else if (iter.hasNext()) { + // mid + iter.next(); + if (!node.mid) { + node.mid = new TernarySearchTreeNode(); + node.mid.segment = iter.value(); + } + node = node.mid; + } else { + break; + } + } + const oldElement = node.value; + node.value = element; + node.key = key; + return oldElement; + } + + get(key: K): V | undefined { + return this._getNode(key)?.value; + } + + private _getNode(key: K) { + const iter = this._iter.reset(key); + let node = this._root; + while (node) { + const val = iter.cmp(node.segment); + if (val > 0) { + // left + node = node.left; + } else if (val < 0) { + // right + node = node.right; + } else if (iter.hasNext()) { + // mid + iter.next(); + node = node.mid; + } else { + break; + } + } + return node; + } + + has(key: K): boolean { + const node = this._getNode(key); + return !(node?.value === undefined && node?.mid === undefined); + } + + delete(key: K): void { + return this._delete(key, false); + } + + deleteSuperstr(key: K): void { + return this._delete(key, true); + } + + private _delete(key: K, superStr: boolean): void { + const iter = this._iter.reset(key); + const stack: [-1 | 0 | 1, TernarySearchTreeNode][] = []; + let node = this._root; + + // find and unset node + while (node) { + const val = iter.cmp(node.segment); + if (val > 0) { + // left + stack.push([1, node]); + node = node.left; + } else if (val < 0) { + // right + stack.push([-1, node]); + node = node.right; + } else if (iter.hasNext()) { + // mid + iter.next(); + stack.push([0, node]); + node = node.mid; + } else { + if (superStr) { + // remove children + node.left = undefined; + node.mid = undefined; + node.right = undefined; + } else { + // remove element + node.value = undefined; + } + + // clean up empty nodes + while (stack.length > 0 && node.isEmpty()) { + let [dir, parent] = stack.pop()!; + switch (dir) { + case 1: + parent.left = undefined; + break; + case 0: + parent.mid = undefined; + break; + case -1: + parent.right = undefined; + break; + } + node = parent; + } + break; + } + } + } + + findSubstr(key: K): V | undefined { + const iter = this._iter.reset(key); + let node = this._root; + let candidate: V | undefined = undefined; + while (node) { + const val = iter.cmp(node.segment); + if (val > 0) { + // left + node = node.left; + } else if (val < 0) { + // right + node = node.right; + } else if (iter.hasNext()) { + // mid + iter.next(); + candidate = node.value || candidate; + node = node.mid; + } else { + break; + } + } + return (node && node.value) || candidate; + } + + findSuperstr(key: K): IterableIterator<[K, V]> | undefined { + const iter = this._iter.reset(key); + let node = this._root; + while (node) { + const val = iter.cmp(node.segment); + if (val > 0) { + // left + node = node.left; + } else if (val < 0) { + // right + node = node.right; + } else if (iter.hasNext()) { + // mid + iter.next(); + node = node.mid; + } else { + // collect + if (!node.mid) { + return undefined; + } else { + return this._entries(node.mid); + } + } + } + return undefined; + } + + forEach(callback: (value: V, index: K) => any): void { + for (const [key, value] of this) { + callback(value, key); + } + } + + *[Symbol.iterator](): IterableIterator<[K, V]> { + yield* this._entries(this._root); + } + + private *_entries(node: TernarySearchTreeNode | undefined): IterableIterator<[K, V]> { + if (node) { + // left + yield* this._entries(node.left); + + // node + if (node.value) { + // callback(node.value, this._iter.join(parts)); + yield [node.key, node.value]; + } + // mid + yield* this._entries(node.mid); + + // right + yield* this._entries(node.right); + } + } +} + +export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (substring: string, ...args: any[]) => Promise): Promise { + const promises: Promise[] = []; + str.replace(regex, (match, ...args) => { + const promise = asyncFn(match, ...args); + promises.push(promise); + return ''; + }); + const data = await Promise.all(promises); + let offset = 0; + return str.replace(regex, () => data[offset++]); +} diff --git a/src/common/webview.ts b/src/common/webview.ts index 102da03e35..0376887e6b 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -1,138 +1,139 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { commands } from './executeCommands'; - -export const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; - -export interface IRequestMessage { - req: string; - command: string; - args: T; -} - -export interface IReplyMessage { - seq?: string; - err?: any; - res?: any; -} - -export function getNonce() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} - -export class WebviewBase { - protected _webview?: vscode.Webview; - protected _disposables: vscode.Disposable[] = []; - - private _waitForReady: Promise; - private _onIsReady: vscode.EventEmitter = new vscode.EventEmitter(); - - protected readonly MESSAGE_UNHANDLED: string = 'message not handled'; - - constructor() { - this._waitForReady = new Promise(resolve => { - const disposable = this._onIsReady.event(() => { - disposable.dispose(); - resolve(); - }); - }); - } - - public initialize(): void { - const disposable = this._webview?.onDidReceiveMessage( - async message => { - await this._onDidReceiveMessage(message as IRequestMessage); - }, - null, - this._disposables, - ); - if (disposable) { - this._disposables.push(disposable); - } - } - - protected async _onDidReceiveMessage(message: IRequestMessage): Promise { - switch (message.command) { - case 'ready': - this._onIsReady.fire(); - return; - default: - return this.MESSAGE_UNHANDLED; - } - } - - protected async _postMessage(message: any) { - // Without the following ready check, we can end up in a state where the message handler in the webview - // isn't ready for any of the messages we post. - await this._waitForReady; - this._webview?.postMessage({ - res: message, - }); - } - - protected async _replyMessage(originalMessage: IRequestMessage, message: any) { - const reply: IReplyMessage = { - seq: originalMessage.req, - res: message, - }; - this._webview?.postMessage(reply); - } - - protected async _throwError(originalMessage: IRequestMessage | undefined, error: any) { - const reply: IReplyMessage = { - seq: originalMessage?.req, - err: error, - }; - this._webview?.postMessage(reply); - } - - public dispose() { - this._disposables.forEach(d => d.dispose()); - } -} - -export class WebviewViewBase extends WebviewBase { - public readonly viewType: string; - protected _view?: vscode.WebviewView; - - constructor( - protected readonly _extensionUri: vscode.Uri) { - super(); - } - - protected resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken) { - this._view = webviewView; - this._webview = webviewView.webview; - super.initialize(); - webviewView.webview.options = { - // Allow scripts in the webview - enableScripts: true, - - localResourceRoots: [this._extensionUri], - }; - this._disposables.push(this._view.onDidDispose(() => { - this._webview = undefined; - this._view = undefined; - })); - } - - public show() { - if (this._view) { - this._view.show(); - } else { - commands.focusView(this.viewType); - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { commands } from './executeCommands'; + +export const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; + +export interface IRequestMessage { + req: string; + command: string; + args: T; +} + +export interface IReplyMessage { + seq?: string; + err?: any; + res?: any; +} + +export function getNonce() { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +export class WebviewBase { + protected _webview?: vscode.Webview; + protected _disposables: vscode.Disposable[] = []; + + private _waitForReady: Promise; + private _onIsReady: vscode.EventEmitter = new vscode.EventEmitter(); + + protected readonly MESSAGE_UNHANDLED: string = 'message not handled'; + + constructor() { + this._waitForReady = new Promise(resolve => { + const disposable = this._onIsReady.event(() => { + disposable.dispose(); + resolve(); + }); + }); + } + + public initialize(): void { + const disposable = this._webview?.onDidReceiveMessage( + async message => { + await this._onDidReceiveMessage(message as IRequestMessage); + }, + null, + this._disposables, + ); + if (disposable) { + this._disposables.push(disposable); + } + } + + protected async _onDidReceiveMessage(message: IRequestMessage): Promise { + switch (message.command) { + case 'ready': + this._onIsReady.fire(); + return; + default: + return this.MESSAGE_UNHANDLED; + } + } + + protected async _postMessage(message: any) { + // Without the following ready check, we can end up in a state where the message handler in the webview + // isn't ready for any of the messages we post. + await this._waitForReady; + this._webview?.postMessage({ + res: message, + }); + } + + protected async _replyMessage(originalMessage: IRequestMessage, message: any) { + const reply: IReplyMessage = { + seq: originalMessage.req, + res: message, + }; + this._webview?.postMessage(reply); + } + + protected async _throwError(originalMessage: IRequestMessage | undefined, error: any) { + const reply: IReplyMessage = { + seq: originalMessage?.req, + err: error, + }; + this._webview?.postMessage(reply); + } + + public dispose() { + this._disposables.forEach(d => d.dispose()); + } +} + +export class WebviewViewBase extends WebviewBase { + public readonly viewType: string; + protected _view?: vscode.WebviewView; + + constructor( + protected readonly _extensionUri: vscode.Uri) { + super(); + } + + protected resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken) { + this._view = webviewView; + this._webview = webviewView.webview; + super.initialize(); + webviewView.webview.options = { + // Allow scripts in the webview + enableScripts: true, + + localResourceRoots: [this._extensionUri], + }; + this._disposables.push(this._view.onDidDispose(() => { + this._webview = undefined; + this._view = undefined; + })); + } + + public show() { + if (this._view) { + this._view.show(); + } else { + commands.focusView(this.viewType); + } + } +} diff --git a/src/constants.ts b/src/constants.ts index 9a22a865f9..57cd5bd4e2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,11 +1,13 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const EXTENSION_ID = 'GitHub.vscode-pull-request-github'; -export const VSLS_REQUEST_NAME = 'git'; -export const VSLS_GIT_PR_SESSION_NAME = 'ghpr'; -export const VSLS_REPOSITORY_INITIALIZATION_NAME = 'initialize'; -export const VSLS_STATE_CHANGE_NOTIFY_NAME = 'statechange'; -export const FOCUS_REVIEW_MODE = 'github:focusedReview'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export const EXTENSION_ID = 'GitHub.vscode-pull-request-github'; +export const VSLS_REQUEST_NAME = 'git'; +export const VSLS_GIT_PR_SESSION_NAME = 'ghpr'; +export const VSLS_REPOSITORY_INITIALIZATION_NAME = 'initialize'; +export const VSLS_STATE_CHANGE_NOTIFY_NAME = 'statechange'; +export const FOCUS_REVIEW_MODE = 'github:focusedReview'; +export const FAKE_CONSt = 'not a real value'; diff --git a/src/env/browser/net.ts b/src/env/browser/net.ts index 39baea382f..0c1790b6be 100644 --- a/src/env/browser/net.ts +++ b/src/env/browser/net.ts @@ -1,6 +1,7 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const agent = undefined; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export const agent = undefined; diff --git a/src/env/browser/ssh.ts b/src/env/browser/ssh.ts index 511b33ac45..50772b5eac 100644 --- a/src/env/browser/ssh.ts +++ b/src/env/browser/ssh.ts @@ -1,116 +1,117 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { parse as parseConfig } from 'ssh-config'; -import Logger from '../../common/logger'; - -const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; -const URL_SCHEME_RE = /^([a-z-+]+):\/\//; - -export const sshParse = (url: string): Config | undefined => { - const urlMatch = URL_SCHEME_RE.exec(url); - if (urlMatch) { - const [fullSchemePrefix, scheme] = urlMatch; - if (scheme.includes('ssh')) { - url = url.slice(fullSchemePrefix.length); - } else { - return; - } - } - const match = SSH_URL_RE.exec(url); - if (!match) { - return; - } - const [, User, Host, path] = match; - return { User, Host, path }; -}; - -/** - * Parse and resolve an SSH url. Resolves host aliases using the configuration - * specified by ~/.ssh/config, if present. - * - * Examples: - * - * resolve("git@github.com:Microsoft/vscode") - * { - * Host: 'github.com', - * HostName: 'github.com', - * User: 'git', - * path: 'Microsoft/vscode', - * } - * - * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) - * { - * Host: 'hub', - * HostName: 'github.com', - * User: 'git', - * path: 'queerviolet/vscode', - * } - * - * @param {string} url the url to parse - * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) - * @returns {Config} - */ -export const resolve = (url: string, resolveConfig = Resolvers.current) => { - const config = sshParse(url); - return config && resolveConfig(config); -}; - -export function baseResolver(config: Config): Config { - return { - ...config, - Hostname: config.Host, - }; -} - -/** - * SSH Config interface - * - * Note that this interface atypically capitalizes field names. This is for consistency - * with SSH config files. - */ -export interface Config { - Host: string; - [param: string]: string; -} - -/** - * ConfigResolvers take a config, resolve some additional data (perhaps using - * a config file), and return a new Config. - */ -export type ConfigResolver = (config: Config) => Config; - -export function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver { - const resolvers = chain.filter(x => !!x) as ConfigResolver[]; - return (config: Config) => - resolvers.reduce((resolved, next) => { - try { - return { - ...resolved, - ...next(resolved), - }; - } catch (err) { - // We cannot trust that some resolvers are not going to throw (i.e user has malformed .ssh/config file). - // Since we can't guarantee that ssh-config package won't throw and we're reducing over the entire chain of resolvers, - // we'll skip erroneous resolvers for now and log. Potentially can validate - Logger.warn(`Failed to parse config for '${config.Host}, this can occur when the extension configurations is invalid or system ssh config files are malformed. Skipping erroneous resolver for now.'`); - return resolved; - } - }, config); -} - -export function resolverFromConfig(text: string): ConfigResolver { - const config = parseConfig(text); - return h => config.compute(h.Host); -} - -export class Resolvers { - static default = baseResolver; - - static fromConfig(conf: string) { - return chainResolvers(baseResolver, resolverFromConfig(conf)); - } - - static current = Resolvers.default; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { parse as parseConfig } from 'ssh-config'; + +import Logger from '../../common/logger'; + +const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; +const URL_SCHEME_RE = /^([a-z-+]+):\/\//; + +export const sshParse = (url: string): Config | undefined => { + const urlMatch = URL_SCHEME_RE.exec(url); + if (urlMatch) { + const [fullSchemePrefix, scheme] = urlMatch; + if (scheme.includes('ssh')) { + url = url.slice(fullSchemePrefix.length); + } else { + return; + } + } + const match = SSH_URL_RE.exec(url); + if (!match) { + return; + } + const [, User, Host, path] = match; + return { User, Host, path }; +}; + +/** + * Parse and resolve an SSH url. Resolves host aliases using the configuration + * specified by ~/.ssh/config, if present. + * + * Examples: + * + * resolve("git@github.com:Microsoft/vscode") + * { + * Host: 'github.com', + * HostName: 'github.com', + * User: 'git', + * path: 'Microsoft/vscode', + * } + * + * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) + * { + * Host: 'hub', + * HostName: 'github.com', + * User: 'git', + * path: 'queerviolet/vscode', + * } + * + * @param {string} url the url to parse + * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) + * @returns {Config} + */ +export const resolve = (url: string, resolveConfig = Resolvers.current) => { + const config = sshParse(url); + return config && resolveConfig(config); +}; + +export function baseResolver(config: Config): Config { + return { + ...config, + Hostname: config.Host, + }; +} + +/** + * SSH Config interface + * + * Note that this interface atypically capitalizes field names. This is for consistency + * with SSH config files. + */ +export interface Config { + Host: string; + [param: string]: string; +} + +/** + * ConfigResolvers take a config, resolve some additional data (perhaps using + * a config file), and return a new Config. + */ +export type ConfigResolver = (config: Config) => Config; + +export function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver { + const resolvers = chain.filter(x => !!x) as ConfigResolver[]; + return (config: Config) => + resolvers.reduce((resolved, next) => { + try { + return { + ...resolved, + ...next(resolved), + }; + } catch (err) { + // We cannot trust that some resolvers are not going to throw (i.e user has malformed .ssh/config file). + // Since we can't guarantee that ssh-config package won't throw and we're reducing over the entire chain of resolvers, + // we'll skip erroneous resolvers for now and log. Potentially can validate + Logger.warn(`Failed to parse config for '${config.Host}, this can occur when the extension configurations is invalid or system ssh config files are malformed. Skipping erroneous resolver for now.'`); + return resolved; + } + }, config); +} + +export function resolverFromConfig(text: string): ConfigResolver { + const config = parseConfig(text); + return h => config.compute(h.Host); +} + +export class Resolvers { + static default = baseResolver; + + static fromConfig(conf: string) { + return chainResolvers(baseResolver, resolverFromConfig(conf)); + } + + static current = Resolvers.default; +} diff --git a/src/env/node/net.ts b/src/env/node/net.ts index ec1f6fd5ea..4f197bc930 100644 --- a/src/env/node/net.ts +++ b/src/env/node/net.ts @@ -1,32 +1,33 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Agent, globalAgent } from 'https'; -import { URL } from 'url'; -import { httpsOverHttp } from 'tunnel'; -import { l10n, window } from 'vscode'; - -export const agent = getAgent(); - -/** - * Return an https agent for the given proxy URL, or return the - * global https agent if the URL was empty or invalid. - * - * @param {string} url the proxy URL, (default: `process.env.HTTPS_PROXY`) - * @returns {https.Agent} - */ -function getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { - if (!url) { - return globalAgent; - } - try { - const { hostname, port, username, password } = new URL(url); - const auth = username && password && `${username}:${password}`; - return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); - } catch (e) { - window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', (e as Error).message)); - return globalAgent; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Agent, globalAgent } from 'https'; +import { URL } from 'url'; +import { httpsOverHttp } from 'tunnel'; +import { l10n, window } from 'vscode'; + +export const agent = getAgent(); + +/** + * Return an https agent for the given proxy URL, or return the + * global https agent if the URL was empty or invalid. + * + * @param {string} url the proxy URL, (default: `process.env.HTTPS_PROXY`) + * @returns {https.Agent} + */ +function getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { + if (!url) { + return globalAgent; + } + try { + const { hostname, port, username, password } = new URL(url); + const auth = username && password && `${username}:${password}`; + return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); + } catch (e) { + window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', (e as Error).message)); + return globalAgent; + } +} diff --git a/src/env/node/ssh.ts b/src/env/node/ssh.ts index 848b63085b..df0b0b16df 100644 --- a/src/env/node/ssh.ts +++ b/src/env/node/ssh.ts @@ -1,60 +1,61 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { readFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; -import Logger from '../../common/logger'; -import { baseResolver, chainResolvers, ConfigResolver, resolverFromConfig, sshParse } from '../browser/ssh'; - -export class Resolvers { - static default = chainResolvers(baseResolver, resolverFromConfigFile()); - - static fromConfig(conf: string) { - return chainResolvers(baseResolver, resolverFromConfig(conf)); - } - - static current = Resolvers.default; -} - -/** - * Parse and resolve an SSH url. Resolves host aliases using the configuration - * specified by ~/.ssh/config, if present. - * - * Examples: - * - * resolve("git@github.com:Microsoft/vscode") - * { - * Host: 'github.com', - * HostName: 'github.com', - * User: 'git', - * path: 'Microsoft/vscode', - * } - * - * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) - * { - * Host: 'hub', - * HostName: 'github.com', - * User: 'git', - * path: 'queerviolet/vscode', - * } - * - * @param {string} url the url to parse - * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) - * @returns {Config} - */ -export const resolve = (url: string, resolveConfig = Resolvers.current) => { - const config = sshParse(url); - return config && resolveConfig(config); -}; - -function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): ConfigResolver | undefined { - try { - const config = readFileSync(configPath).toString(); - return resolverFromConfig(config); - } catch (error) { - Logger.warn(`${configPath}: ${error.message}`); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import Logger from '../../common/logger'; +import { baseResolver, chainResolvers, ConfigResolver, resolverFromConfig, sshParse } from '../browser/ssh'; + +export class Resolvers { + static default = chainResolvers(baseResolver, resolverFromConfigFile()); + + static fromConfig(conf: string) { + return chainResolvers(baseResolver, resolverFromConfig(conf)); + } + + static current = Resolvers.default; +} + +/** + * Parse and resolve an SSH url. Resolves host aliases using the configuration + * specified by ~/.ssh/config, if present. + * + * Examples: + * + * resolve("git@github.com:Microsoft/vscode") + * { + * Host: 'github.com', + * HostName: 'github.com', + * User: 'git', + * path: 'Microsoft/vscode', + * } + * + * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) + * { + * Host: 'hub', + * HostName: 'github.com', + * User: 'git', + * path: 'queerviolet/vscode', + * } + * + * @param {string} url the url to parse + * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) + * @returns {Config} + */ +export const resolve = (url: string, resolveConfig = Resolvers.current) => { + const config = sshParse(url); + return config && resolveConfig(config); +}; + +function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): ConfigResolver | undefined { + try { + const config = readFileSync(configPath).toString(); + return resolverFromConfig(config); + } catch (error) { + Logger.warn(`${configPath}: ${error.message}`); + } +} diff --git a/src/experimentationService.ts b/src/experimentationService.ts index a792b4e768..5ffd1e3b42 100644 --- a/src/experimentationService.ts +++ b/src/experimentationService.ts @@ -1,127 +1,128 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import TelemetryReporter from '@vscode/extension-telemetry'; -import * as vscode from 'vscode'; -import { - getExperimentationService, - IExperimentationService, - IExperimentationTelemetry, - TargetPopulation, -} from 'vscode-tas-client'; - -/* __GDPR__ - "query-expfeature" : { - "ABExp.queriedFeature": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } -*/ - -export class ExperimentationTelemetry implements IExperimentationTelemetry { - private sharedProperties: Record = {}; - - constructor(private baseReporter: TelemetryReporter | undefined) { } - - sendTelemetryEvent(eventName: string, properties?: Record, measurements?: Record) { - this.baseReporter?.sendTelemetryEvent( - eventName, - { - ...this.sharedProperties, - ...properties, - }, - measurements, - ); - } - - sendTelemetryErrorEvent( - eventName: string, - properties?: Record, - _measurements?: Record, - ) { - this.baseReporter?.sendTelemetryErrorEvent(eventName, { - ...this.sharedProperties, - ...properties, - }); - } - - setSharedProperty(name: string, value: string): void { - this.sharedProperties[name] = value; - } - - postEvent(eventName: string, props: Map): void { - const event: Record = {}; - for (const [key, value] of props) { - event[key] = value; - } - this.sendTelemetryEvent(eventName, event); - } - - async dispose(): Promise { - return this.baseReporter?.dispose(); - } -} - -function getTargetPopulation(): TargetPopulation { - switch (vscode.env.uriScheme) { - case 'vscode': - return TargetPopulation.Public; - case 'vscode-insiders': - return TargetPopulation.Insiders; - case 'vscode-exploration': - return TargetPopulation.Internal; - case 'code-oss': - return TargetPopulation.Team; - default: - return TargetPopulation.Public; - } -} - -class NullExperimentationService implements IExperimentationService { - readonly initializePromise: Promise = Promise.resolve(); - readonly initialFetch: Promise = Promise.resolve(); - - isFlightEnabled(_flight: string): boolean { - return false; - } - - isCachedFlightEnabled(_flight: string): Promise { - return Promise.resolve(false); - } - - isFlightEnabledAsync(_flight: string): Promise { - return Promise.resolve(false); - } - - getTreatmentVariable(_configId: string, _name: string): T | undefined { - return undefined; - } - - getTreatmentVariableAsync( - _configId: string, - _name: string, - ): Promise { - return Promise.resolve(undefined); - } -} - -export async function createExperimentationService( - context: vscode.ExtensionContext, - experimentationTelemetry: ExperimentationTelemetry, -): Promise { - const id = context.extension.id; - const name = context.extension.packageJSON['name']; - const version: string = context.extension.packageJSON['version']; - const targetPopulation = getTargetPopulation(); - - // We only create a real experimentation service for the stable version of the extension, not insiders. - return name === 'vscode-pull-request-github' - ? getExperimentationService( - id, - version, - targetPopulation, - experimentationTelemetry, - context.globalState, - ) - : new NullExperimentationService(); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import TelemetryReporter from '@vscode/extension-telemetry'; +import * as vscode from 'vscode'; +import { + getExperimentationService, + IExperimentationService, + IExperimentationTelemetry, + TargetPopulation, +} from 'vscode-tas-client'; + +/* __GDPR__ + "query-expfeature" : { + "ABExp.queriedFeature": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } +*/ + +export class ExperimentationTelemetry implements IExperimentationTelemetry { + private sharedProperties: Record = {}; + + constructor(private baseReporter: TelemetryReporter | undefined) { } + + sendTelemetryEvent(eventName: string, properties?: Record, measurements?: Record) { + this.baseReporter?.sendTelemetryEvent( + eventName, + { + ...this.sharedProperties, + ...properties, + }, + measurements, + ); + } + + sendTelemetryErrorEvent( + eventName: string, + properties?: Record, + _measurements?: Record, + ) { + this.baseReporter?.sendTelemetryErrorEvent(eventName, { + ...this.sharedProperties, + ...properties, + }); + } + + setSharedProperty(name: string, value: string): void { + this.sharedProperties[name] = value; + } + + postEvent(eventName: string, props: Map): void { + const event: Record = {}; + for (const [key, value] of props) { + event[key] = value; + } + this.sendTelemetryEvent(eventName, event); + } + + async dispose(): Promise { + return this.baseReporter?.dispose(); + } +} + +function getTargetPopulation(): TargetPopulation { + switch (vscode.env.uriScheme) { + case 'vscode': + return TargetPopulation.Public; + case 'vscode-insiders': + return TargetPopulation.Insiders; + case 'vscode-exploration': + return TargetPopulation.Internal; + case 'code-oss': + return TargetPopulation.Team; + default: + return TargetPopulation.Public; + } +} + +class NullExperimentationService implements IExperimentationService { + readonly initializePromise: Promise = Promise.resolve(); + readonly initialFetch: Promise = Promise.resolve(); + + isFlightEnabled(_flight: string): boolean { + return false; + } + + isCachedFlightEnabled(_flight: string): Promise { + return Promise.resolve(false); + } + + isFlightEnabledAsync(_flight: string): Promise { + return Promise.resolve(false); + } + + getTreatmentVariable(_configId: string, _name: string): T | undefined { + return undefined; + } + + getTreatmentVariableAsync( + _configId: string, + _name: string, + ): Promise { + return Promise.resolve(undefined); + } +} + +export async function createExperimentationService( + context: vscode.ExtensionContext, + experimentationTelemetry: ExperimentationTelemetry, +): Promise { + const id = context.extension.id; + const name = context.extension.packageJSON['name']; + const version: string = context.extension.packageJSON['version']; + const targetPopulation = getTargetPopulation(); + + // We only create a real experimentation service for the stable version of the extension, not insiders. + return name === 'vscode-pull-request-github' + ? getExperimentationService( + id, + version, + targetPopulation, + experimentationTelemetry, + context.globalState, + ) + : new NullExperimentationService(); +} diff --git a/src/extension.ts b/src/extension.ts index fe6d8a2156..12f0babb6c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,389 +1,390 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; -import TelemetryReporter from '@vscode/extension-telemetry'; -import * as vscode from 'vscode'; -import { LiveShare } from 'vsls/vscode.js'; -import { PostCommitCommandsProvider, Repository } from './api/api'; -import { GitApiImpl } from './api/api1'; -import { registerCommands } from './commands'; -import { commands } from './common/executeCommands'; -import Logger from './common/logger'; -import * as PersistentState from './common/persistentState'; -import { parseRepositoryRemotes } from './common/remote'; -import { Resource } from './common/resources'; -import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; -import { TemporaryState } from './common/temporaryState'; -import { Schemes, handler as uriHandler } from './common/uri'; -import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; -import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; -import { CredentialStore } from './github/credentials'; -import { FolderRepositoryManager } from './github/folderRepositoryManager'; -import { RepositoriesManager } from './github/repositoriesManager'; -import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; -import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; -import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; -import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; -import { CompareChanges } from './view/compareChangesTreeDataProvider'; -import { CreatePullRequestHelper } from './view/createPullRequestHelper'; -import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; -import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; -import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; -import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider'; -import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; -import { ReviewManager, ShowPullRequest } from './view/reviewManager'; -import { ReviewsManager } from './view/reviewsManager'; -import { WebviewViewCoordinator } from './view/webviewViewCoordinator'; - -const ingestionKey = '0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255'; - -let telemetry: ExperimentationTelemetry; - -const PROMPTS_SCOPE = 'prompts'; -const PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY = 'createPROnPublish'; - -async function init( - context: vscode.ExtensionContext, - git: GitApiImpl, - credentialStore: CredentialStore, - repositories: Repository[], - tree: PullRequestsTreeDataProvider, - liveshareApiPromise: Promise, - showPRController: ShowPullRequest, - reposManager: RepositoriesManager, -): Promise { - context.subscriptions.push(Logger); - Logger.appendLine('Git repository found, initializing review manager and pr tree view.'); - - vscode.authentication.onDidChangeSessions(async e => { - if (e.provider.id === 'github') { - await reposManager.clearCredentialCache(); - if (reviewsManager) { - reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); - } - } - }); - - context.subscriptions.push( - git.onDidPublish(async e => { - // Only notify on branch publish events - if (!e.branch) { - return; - } - - if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'ask' | 'never' | undefined>(BRANCH_PUBLISH) !== 'ask') { - return; - } - - const reviewManager = reviewsManager.reviewManagers.find( - manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString(), - ); - if (reviewManager?.isCreatingPullRequest) { - return; - } - - const folderManager = reposManager.folderManagers.find( - manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString()); - - if (!folderManager || folderManager.gitHubRepositories.length === 0) { - return; - } - - const defaults = await folderManager.getPullRequestDefaults(); - if (defaults.base === e.branch) { - return; - } - - const create = vscode.l10n.t('Create Pull Request...'); - const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('Would you like to create a Pull Request for branch \'{0}\'?', e.branch), - create, - dontShowAgain, - ); - if (result === create) { - reviewManager?.createPullRequest(e.branch); - } else if (result === dontShowAgain) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); - } - }), - ); - - context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); - - // Sort the repositories to match folders in a multiroot workspace (if possible). - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders) { - repositories = repositories.sort((a, b) => { - let indexA = workspaceFolders.length; - let indexB = workspaceFolders.length; - for (let i = 0; i < workspaceFolders.length; i++) { - if (workspaceFolders[i].uri.toString() === a.rootUri.toString()) { - indexA = i; - } else if (workspaceFolders[i].uri.toString() === b.rootUri.toString()) { - indexB = i; - } - if (indexA !== workspaceFolders.length && indexB !== workspaceFolders.length) { - break; - } - } - return indexA - indexB; - }); - } - - liveshareApiPromise.then(api => { - if (api) { - // register the pull request provider to suggest PR contacts - api.registerContactServiceProvider('github-pr', new GitHubContactServiceProvider(reposManager)); - } - }); - - const changesTree = new PullRequestChangesTreeDataProvider(context, git, reposManager); - context.subscriptions.push(changesTree); - - const activePrViewCoordinator = new WebviewViewCoordinator(context); - context.subscriptions.push(activePrViewCoordinator); - const createPrHelper = new CreatePullRequestHelper(); - context.subscriptions.push(createPrHelper); - let reviewManagerIndex = 0; - const reviewManagers = reposManager.folderManagers.map( - folderManager => new ReviewManager(reviewManagerIndex++, context, folderManager.repository, folderManager, telemetry, changesTree, tree, showPRController, activePrViewCoordinator, createPrHelper, git), - ); - context.subscriptions.push(new FileTypeDecorationProvider(reposManager)); - - const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git); - context.subscriptions.push(reviewsManager); - - git.onDidChangeState(() => { - Logger.appendLine(`Git initialization state changed: state=${git.state}`); - reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); - }); - - git.onDidOpenRepository(repo => { - function addRepo() { - // Make sure we don't already have a folder manager for this repo. - const existing = reposManager.folderManagers.find(manager => manager.repository.rootUri.toString() === repo.rootUri.toString()); - if (existing) { - Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`); - return; - } - const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore); - reposManager.insertFolderManager(newFolderManager); - const newReviewManager = new ReviewManager( - reviewManagerIndex++, - context, - newFolderManager.repository, - newFolderManager, - telemetry, - changesTree, - tree, - showPRController, - activePrViewCoordinator, - createPrHelper, - git - ); - reviewsManager.addReviewManager(newReviewManager); - } - addRepo(); - tree.notificationProvider.refreshOrLaunchPolling(); - const disposable = repo.state.onDidChange(() => { - Logger.appendLine(`Repo state for ${repo.rootUri} changed.`); - addRepo(); - disposable.dispose(); - }); - }); - - git.onDidCloseRepository(repo => { - reposManager.removeRepo(repo); - reviewsManager.removeReviewManager(repo); - tree.notificationProvider.refreshOrLaunchPolling(); - }); - - tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), credentialStore); - - context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); - - registerCommands(context, reposManager, reviewsManager, telemetry, tree); - - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - - const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry); - context.subscriptions.push(issuesFeatures); - await issuesFeatures.initialize(); - - context.subscriptions.push(new GitLensIntegration()); - - await vscode.commands.executeCommand('setContext', 'github:initialized', true); - - registerPostCommitCommandsProvider(reposManager, git); - // Make sure any compare changes tabs, which come from the create flow, are closed. - CompareChanges.closeTabs(); - /* __GDPR__ - "startup" : {} - */ - telemetry.sendTelemetryEvent('startup'); -} - -export async function activate(context: vscode.ExtensionContext): Promise { - Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { - const stable = vscode.extensions.getExtension('github.vscode-pull-request-github'); - if (stable !== undefined) { - throw new Error( - 'GitHub Pull Requests and Issues Nightly cannot be used while GitHub Pull Requests and Issues is also installed. Please ensure that only one version of the extension is installed.', - ); - } - } - - const showPRController = new ShowPullRequest(); - vscode.commands.registerCommand('github.api.preloadPullRequest', async (shouldShow: boolean) => { - await vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); - await commands.focusView('github:activePullRequest:welcome'); - showPRController.shouldShow = shouldShow; - }); - const openDiff = vscode.workspace.getConfiguration(GIT, null).get(OPEN_DIFF_ON_CLICK, true); - await vscode.commands.executeCommand('setContext', 'openDiffOnClick', openDiff); - - // initialize resources - Resource.initialize(context); - Logger.debug('Creating API implementation.', 'Activation'); - const apiImpl = new GitApiImpl(); - - telemetry = new ExperimentationTelemetry(new TelemetryReporter(ingestionKey)); - context.subscriptions.push(telemetry); - - await deferredActivate(context, apiImpl, showPRController); - - return apiImpl; -} - -async function doRegisterBuiltinGitProvider(context: vscode.ExtensionContext, credentialStore: CredentialStore, apiImpl: GitApiImpl): Promise { - const builtInGitProvider = await registerBuiltinGitProvider(credentialStore, apiImpl); - if (builtInGitProvider) { - context.subscriptions.push(builtInGitProvider); - return true; - } - return false; -} - -function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, git: GitApiImpl) { - const componentId = 'GitPostCommitCommands'; - class Provider implements PostCommitCommandsProvider { - - getCommands(repository: Repository) { - Logger.debug(`Looking for remote. Comparing ${repository.state.remotes.length} local repo remotes with ${reposManager.folderManagers.reduce((prev, curr) => prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); - const repoRemotes = parseRepositoryRemotes(repository); - - const found = reposManager.folderManagers.find(folderManager => folderManager.findRepo(githubRepo => { - return !!repoRemotes.find(remote => { - return remote.equals(githubRepo.remote); - }); - })); - Logger.debug(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); - return found ? [{ - command: 'pr.pushAndCreate', - title: vscode.l10n.t('{0} Commit & Create Pull Request', '$(git-pull-request-create)'), - tooltip: vscode.l10n.t('Commit & Create Pull Request') - }] : []; - } - } - - function hasGitHubRepos(): boolean { - return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); - } - function tryRegister(): boolean { - Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); - if (hasGitHubRepos()) { - Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); - git.registerPostCommitCommandsProvider(new Provider()); - return true; - } - return false; - } - - if (!tryRegister()) { - const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { - if (tryRegister()) { - reposDisposable.dispose(); - } - }); - } -} - -async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { - Logger.debug('Registering built in git provider.', 'Activation'); - if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { - const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { - if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { - extensionsChangedDisposable.dispose(); - } - }); - context.subscriptions.push(extensionsChangedDisposable); - } -} - -async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitApiImpl, showPRController: ShowPullRequest) { - Logger.debug('Initializing state.', 'Activation'); - PersistentState.init(context); - // Migrate from state to setting - if (PersistentState.fetch(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY) === false) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); - PersistentState.store(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY, true); - } - TemporaryState.init(context); - Logger.debug('Creating credential store.', 'Activation'); - const credentialStore = new CredentialStore(telemetry, context); - context.subscriptions.push(credentialStore); - const experimentationService = await createExperimentationService(context, telemetry); - await experimentationService.initializePromise; - await experimentationService.isCachedFlightEnabled('githubaa'); - const showBadge = (vscode.env.appHost === 'desktop'); - await credentialStore.create(showBadge ? undefined : { silent: true }); - - deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore); - - Logger.debug('Registering live share git provider.', 'Activation'); - const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); - context.subscriptions.push(liveshareGitProvider); - const liveshareApiPromise = liveshareGitProvider.initialize(); - - context.subscriptions.push(apiImpl); - - Logger.debug('Creating tree view.', 'Activation'); - const reposManager = new RepositoriesManager(credentialStore, telemetry); - context.subscriptions.push(reposManager); - - const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager); - context.subscriptions.push(prTree); - Logger.appendLine('Looking for git repository'); - const repositories = apiImpl.repositories; - Logger.appendLine(`Found ${repositories.length} repositories during activation`); - - let folderManagerIndex = 0; - const folderManagers = repositories.map( - repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore), - ); - context.subscriptions.push(...folderManagers); - for (const folderManager of folderManagers) { - reposManager.insertFolderManager(folderManager); - } - - const inMemPRFileSystemProvider = getInMemPRFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; - const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.')); - readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; - context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); - - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager); -} - -export async function deactivate() { - if (telemetry) { - telemetry.dispose(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +'use strict'; +import TelemetryReporter from '@vscode/extension-telemetry'; +import * as vscode from 'vscode'; +import { LiveShare } from 'vsls/vscode.js'; +import { PostCommitCommandsProvider, Repository } from './api/api'; +import { GitApiImpl } from './api/api1'; +import { registerCommands } from './commands'; +import { commands } from './common/executeCommands'; +import Logger from './common/logger'; +import * as PersistentState from './common/persistentState'; +import { parseRepositoryRemotes } from './common/remote'; +import { Resource } from './common/resources'; +import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { TemporaryState } from './common/temporaryState'; +import { Schemes, handler as uriHandler } from './common/uri'; +import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; +import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; +import { CredentialStore } from './github/credentials'; +import { FolderRepositoryManager } from './github/folderRepositoryManager'; +import { RepositoriesManager } from './github/repositoriesManager'; +import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; +import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; +import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; +import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; +import { CompareChanges } from './view/compareChangesTreeDataProvider'; +import { CreatePullRequestHelper } from './view/createPullRequestHelper'; +import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; +import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; +import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; +import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider'; +import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { ReviewManager, ShowPullRequest } from './view/reviewManager'; +import { ReviewsManager } from './view/reviewsManager'; +import { WebviewViewCoordinator } from './view/webviewViewCoordinator'; + +const ingestionKey = '0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255'; + +let telemetry: ExperimentationTelemetry; + +const PROMPTS_SCOPE = 'prompts'; +const PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY = 'createPROnPublish'; + +async function init( + context: vscode.ExtensionContext, + git: GitApiImpl, + credentialStore: CredentialStore, + repositories: Repository[], + tree: PullRequestsTreeDataProvider, + liveshareApiPromise: Promise, + showPRController: ShowPullRequest, + reposManager: RepositoriesManager, +): Promise { + context.subscriptions.push(Logger); + Logger.appendLine('Git repository found, initializing review manager and pr tree view.'); + + vscode.authentication.onDidChangeSessions(async e => { + if (e.provider.id === 'github') { + await reposManager.clearCredentialCache(); + if (reviewsManager) { + reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); + } + } + }); + + context.subscriptions.push( + git.onDidPublish(async e => { + // Only notify on branch publish events + if (!e.branch) { + return; + } + + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'ask' | 'never' | undefined>(BRANCH_PUBLISH) !== 'ask') { + return; + } + + const reviewManager = reviewsManager.reviewManagers.find( + manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString(), + ); + if (reviewManager?.isCreatingPullRequest) { + return; + } + + const folderManager = reposManager.folderManagers.find( + manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString()); + + if (!folderManager || folderManager.gitHubRepositories.length === 0) { + return; + } + + const defaults = await folderManager.getPullRequestDefaults(); + if (defaults.base === e.branch) { + return; + } + + const create = vscode.l10n.t('Create Pull Request...'); + const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('Would you like to create a Pull Request for branch \'{0}\'?', e.branch), + create, + dontShowAgain, + ); + if (result === create) { + reviewManager?.createPullRequest(e.branch); + } else if (result === dontShowAgain) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); + } + }), + ); + + context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); + + // Sort the repositories to match folders in a multiroot workspace (if possible). + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + repositories = repositories.sort((a, b) => { + let indexA = workspaceFolders.length; + let indexB = workspaceFolders.length; + for (let i = 0; i < workspaceFolders.length; i++) { + if (workspaceFolders[i].uri.toString() === a.rootUri.toString()) { + indexA = i; + } else if (workspaceFolders[i].uri.toString() === b.rootUri.toString()) { + indexB = i; + } + if (indexA !== workspaceFolders.length && indexB !== workspaceFolders.length) { + break; + } + } + return indexA - indexB; + }); + } + + liveshareApiPromise.then(api => { + if (api) { + // register the pull request provider to suggest PR contacts + api.registerContactServiceProvider('github-pr', new GitHubContactServiceProvider(reposManager)); + } + }); + + const changesTree = new PullRequestChangesTreeDataProvider(context, git, reposManager); + context.subscriptions.push(changesTree); + + const activePrViewCoordinator = new WebviewViewCoordinator(context); + context.subscriptions.push(activePrViewCoordinator); + const createPrHelper = new CreatePullRequestHelper(); + context.subscriptions.push(createPrHelper); + let reviewManagerIndex = 0; + const reviewManagers = reposManager.folderManagers.map( + folderManager => new ReviewManager(reviewManagerIndex++, context, folderManager.repository, folderManager, telemetry, changesTree, tree, showPRController, activePrViewCoordinator, createPrHelper, git), + ); + context.subscriptions.push(new FileTypeDecorationProvider(reposManager)); + + const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git); + context.subscriptions.push(reviewsManager); + + git.onDidChangeState(() => { + Logger.appendLine(`Git initialization state changed: state=${git.state}`); + reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); + }); + + git.onDidOpenRepository(repo => { + function addRepo() { + // Make sure we don't already have a folder manager for this repo. + const existing = reposManager.folderManagers.find(manager => manager.repository.rootUri.toString() === repo.rootUri.toString()); + if (existing) { + Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`); + return; + } + const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore); + reposManager.insertFolderManager(newFolderManager); + const newReviewManager = new ReviewManager( + reviewManagerIndex++, + context, + newFolderManager.repository, + newFolderManager, + telemetry, + changesTree, + tree, + showPRController, + activePrViewCoordinator, + createPrHelper, + git + ); + reviewsManager.addReviewManager(newReviewManager); + } + addRepo(); + tree.notificationProvider.refreshOrLaunchPolling(); + const disposable = repo.state.onDidChange(() => { + Logger.appendLine(`Repo state for ${repo.rootUri} changed.`); + addRepo(); + disposable.dispose(); + }); + }); + + git.onDidCloseRepository(repo => { + reposManager.removeRepo(repo); + reviewsManager.removeReviewManager(repo); + tree.notificationProvider.refreshOrLaunchPolling(); + }); + + tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), credentialStore); + + context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); + + registerCommands(context, reposManager, reviewsManager, telemetry, tree); + + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); + + const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry); + context.subscriptions.push(issuesFeatures); + await issuesFeatures.initialize(); + + context.subscriptions.push(new GitLensIntegration()); + + await vscode.commands.executeCommand('setContext', 'github:initialized', true); + + registerPostCommitCommandsProvider(reposManager, git); + // Make sure any compare changes tabs, which come from the create flow, are closed. + CompareChanges.closeTabs(); + /* __GDPR__ + "startup" : {} + */ + telemetry.sendTelemetryEvent('startup'); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { + const stable = vscode.extensions.getExtension('github.vscode-pull-request-github'); + if (stable !== undefined) { + throw new Error( + 'GitHub Pull Requests and Issues Nightly cannot be used while GitHub Pull Requests and Issues is also installed. Please ensure that only one version of the extension is installed.', + ); + } + } + + const showPRController = new ShowPullRequest(); + vscode.commands.registerCommand('github.api.preloadPullRequest', async (shouldShow: boolean) => { + await vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); + await commands.focusView('github:activePullRequest:welcome'); + showPRController.shouldShow = shouldShow; + }); + const openDiff = vscode.workspace.getConfiguration(GIT, null).get(OPEN_DIFF_ON_CLICK, true); + await vscode.commands.executeCommand('setContext', 'openDiffOnClick', openDiff); + + // initialize resources + Resource.initialize(context); + Logger.debug('Creating API implementation.', 'Activation'); + const apiImpl = new GitApiImpl(); + + telemetry = new ExperimentationTelemetry(new TelemetryReporter(ingestionKey)); + context.subscriptions.push(telemetry); + + await deferredActivate(context, apiImpl, showPRController); + + return apiImpl; +} + +async function doRegisterBuiltinGitProvider(context: vscode.ExtensionContext, credentialStore: CredentialStore, apiImpl: GitApiImpl): Promise { + const builtInGitProvider = await registerBuiltinGitProvider(credentialStore, apiImpl); + if (builtInGitProvider) { + context.subscriptions.push(builtInGitProvider); + return true; + } + return false; +} + +function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, git: GitApiImpl) { + const componentId = 'GitPostCommitCommands'; + class Provider implements PostCommitCommandsProvider { + + getCommands(repository: Repository) { + Logger.debug(`Looking for remote. Comparing ${repository.state.remotes.length} local repo remotes with ${reposManager.folderManagers.reduce((prev, curr) => prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); + const repoRemotes = parseRepositoryRemotes(repository); + + const found = reposManager.folderManagers.find(folderManager => folderManager.findRepo(githubRepo => { + return !!repoRemotes.find(remote => { + return remote.equals(githubRepo.remote); + }); + })); + Logger.debug(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); + return found ? [{ + command: 'pr.pushAndCreate', + title: vscode.l10n.t('{0} Commit & Create Pull Request', '$(git-pull-request-create)'), + tooltip: vscode.l10n.t('Commit & Create Pull Request') + }] : []; + } + } + + function hasGitHubRepos(): boolean { + return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); + } + function tryRegister(): boolean { + Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); + if (hasGitHubRepos()) { + Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); + git.registerPostCommitCommandsProvider(new Provider()); + return true; + } + return false; + } + + if (!tryRegister()) { + const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { + if (tryRegister()) { + reposDisposable.dispose(); + } + }); + } +} + +async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { + Logger.debug('Registering built in git provider.', 'Activation'); + if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { + const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { + if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { + extensionsChangedDisposable.dispose(); + } + }); + context.subscriptions.push(extensionsChangedDisposable); + } +} + +async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitApiImpl, showPRController: ShowPullRequest) { + Logger.debug('Initializing state.', 'Activation'); + PersistentState.init(context); + // Migrate from state to setting + if (PersistentState.fetch(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY) === false) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); + PersistentState.store(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY, true); + } + TemporaryState.init(context); + Logger.debug('Creating credential store.', 'Activation'); + const credentialStore = new CredentialStore(telemetry, context); + context.subscriptions.push(credentialStore); + const experimentationService = await createExperimentationService(context, telemetry); + await experimentationService.initializePromise; + await experimentationService.isCachedFlightEnabled('githubaa'); + const showBadge = (vscode.env.appHost === 'desktop'); + await credentialStore.create(showBadge ? undefined : { silent: true }); + + deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore); + + Logger.debug('Registering live share git provider.', 'Activation'); + const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); + context.subscriptions.push(liveshareGitProvider); + const liveshareApiPromise = liveshareGitProvider.initialize(); + + context.subscriptions.push(apiImpl); + + Logger.debug('Creating tree view.', 'Activation'); + const reposManager = new RepositoriesManager(credentialStore, telemetry); + context.subscriptions.push(reposManager); + + const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + context.subscriptions.push(prTree); + Logger.appendLine('Looking for git repository'); + const repositories = apiImpl.repositories; + Logger.appendLine(`Found ${repositories.length} repositories during activation`); + + let folderManagerIndex = 0; + const folderManagers = repositories.map( + repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore), + ); + context.subscriptions.push(...folderManagers); + for (const folderManager of folderManagers) { + reposManager.insertFolderManager(folderManager); + } + + const inMemPRFileSystemProvider = getInMemPRFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; + const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.')); + readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; + context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); + + await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager); +} + +export async function deactivate() { + if (telemetry) { + telemetry.dispose(); + } +} diff --git a/src/extensionState.ts b/src/extensionState.ts index e45ddecea0..e43fb617e7 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { IAccount } from './github/interface'; // Synced keys @@ -18,10 +19,12 @@ export interface RepoState { stateModifiedTime?: number; } + + export interface ReposState { repos: { [ownerAndRepo: string]: RepoState }; } export function setSyncedKeys(context: vscode.ExtensionContext) { context.globalState.setKeysForSync([NEVER_SHOW_PULL_NOTIFICATION]); -} \ No newline at end of file +} diff --git a/src/gitExtensionIntegration.ts b/src/gitExtensionIntegration.ts index 88ee9cc940..f81fdd96e7 100644 --- a/src/gitExtensionIntegration.ts +++ b/src/gitExtensionIntegration.ts @@ -1,82 +1,83 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { RemoteSource, RemoteSourceProvider } from './@types/git'; -import { AuthProvider } from './common/authentication'; -import { OctokitCommon } from './github/common'; -import { CredentialStore, GitHub } from './github/credentials'; -import { isEnterprise } from './github/utils'; - -interface Repository { - readonly full_name: string; - readonly description: string | null; - readonly clone_url: string; - readonly ssh_url: string; -} - -function repoResponseAsRemoteSource(raw: OctokitCommon.SearchReposResponseItem): RemoteSource { - return { - name: `$(github) ${raw.full_name}`, - description: raw.description || undefined, - url: raw.url, - }; -} - -function asRemoteSource(raw: Repository): RemoteSource { - return { - name: `$(github) ${raw.full_name}`, - description: raw.description || undefined, - url: raw.clone_url, - }; -} - -export class GithubRemoteSourceProvider implements RemoteSourceProvider { - readonly name: string = 'GitHub'; - readonly icon = 'github'; - readonly supportsQuery = true; - - private userReposCache: RemoteSource[] = []; - - constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { - if (isEnterprise(authProviderId)) { - this.name = 'GitHub Enterprise'; - } - } - - async getRemoteSources(query?: string): Promise { - const hub = await this.credentialStore.getHubOrLogin(this.authProviderId); - - if (!hub) { - throw new Error('Could not fetch repositories from GitHub.'); - } - - const [fromUser, fromQuery] = await Promise.all([ - this.getUserRemoteSources(hub, query), - this.getQueryRemoteSources(hub, query), - ]); - - const userRepos = new Set(fromUser.map(r => r.name)); - - return [...fromUser, ...fromQuery.filter(r => !userRepos.has(r.name))]; - } - - private async getUserRemoteSources(hub: GitHub, query?: string): Promise { - if (!query) { - const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 }); - this.userReposCache = res.data.map(asRemoteSource); - } - - return this.userReposCache; - } - - private async getQueryRemoteSources(hub: GitHub, query?: string): Promise { - if (!query) { - return []; - } - - const raw = await hub.octokit.call(hub.octokit.api.search.repos, { q: query, sort: 'updated' }); - return raw.data.items.map(repoResponseAsRemoteSource); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { RemoteSource, RemoteSourceProvider } from './@types/git'; +import { AuthProvider } from './common/authentication'; +import { OctokitCommon } from './github/common'; +import { CredentialStore, GitHub } from './github/credentials'; +import { isEnterprise } from './github/utils'; + +interface Repository { + readonly full_name: string; + readonly description: string | null; + readonly clone_url: string; + readonly ssh_url: string; +} + +function repoResponseAsRemoteSource(raw: OctokitCommon.SearchReposResponseItem): RemoteSource { + return { + name: `$(github) ${raw.full_name}`, + description: raw.description || undefined, + url: raw.url, + }; +} + +function asRemoteSource(raw: Repository): RemoteSource { + return { + name: `$(github) ${raw.full_name}`, + description: raw.description || undefined, + url: raw.clone_url, + }; +} + +export class GithubRemoteSourceProvider implements RemoteSourceProvider { + readonly name: string = 'GitHub'; + readonly icon = 'github'; + readonly supportsQuery = true; + + private userReposCache: RemoteSource[] = []; + + constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { + if (isEnterprise(authProviderId)) { + this.name = 'GitHub Enterprise'; + } + } + + async getRemoteSources(query?: string): Promise { + const hub = await this.credentialStore.getHubOrLogin(this.authProviderId); + + if (!hub) { + throw new Error('Could not fetch repositories from GitHub.'); + } + + const [fromUser, fromQuery] = await Promise.all([ + this.getUserRemoteSources(hub, query), + this.getQueryRemoteSources(hub, query), + ]); + + const userRepos = new Set(fromUser.map(r => r.name)); + + return [...fromUser, ...fromQuery.filter(r => !userRepos.has(r.name))]; + } + + private async getUserRemoteSources(hub: GitHub, query?: string): Promise { + if (!query) { + const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 }); + this.userReposCache = res.data.map(asRemoteSource); + } + + return this.userReposCache; + } + + private async getQueryRemoteSources(hub: GitHub, query?: string): Promise { + if (!query) { + return []; + } + + const raw = await hub.octokit.call(hub.octokit.api.search.repos, { q: query, sort: 'updated' }); + return raw.data.items.map(repoResponseAsRemoteSource); + } +} diff --git a/src/gitProviders/GitHubContactServiceProvider.ts b/src/gitProviders/GitHubContactServiceProvider.ts index ad7f11308c..422890b58e 100644 --- a/src/gitProviders/GitHubContactServiceProvider.ts +++ b/src/gitProviders/GitHubContactServiceProvider.ts @@ -1,148 +1,149 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IAccount } from '../github/interface'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -/** - * The liveshare contact service contract - */ -interface ContactServiceProvider { - requestAsync(type: string, parameters: Object, cancellationToken?: vscode.CancellationToken): Promise; - - readonly onNotified: vscode.Event; -} - -interface NotifyContactServiceEventArgs { - type: string; - body?: any | undefined; -} - -/** - * The liveshare public contact contract - */ -interface Contact { - id: string; - displayName?: string | undefined; - email?: string | undefined; -} - -/** - * A contact service provider for liveshare that would suggest contacts based on the pull request manager - */ -export class GitHubContactServiceProvider implements ContactServiceProvider { - private readonly onNotifiedEmitter = new vscode.EventEmitter(); - - public onNotified: vscode.Event = this.onNotifiedEmitter.event; - - constructor(private readonly pullRequestManager: RepositoriesManager) { - pullRequestManager.folderManagers.forEach(folderManager => { - folderManager.onDidChangeAssignableUsers(e => { - this.notifySuggestedAccounts(e); - }); - }); - } - - public async requestAsync( - type: string, - _parameters: Object, - _cancellationToken?: vscode.CancellationToken, - ): Promise { - let result: Object | null = null; - - switch (type) { - case 'initialize': - result = { - description: 'Pullrequest', - capabilities: { - supportsDispose: false, - supportsInviteLink: false, - supportsPresence: false, - supportsContactPresenceRequest: false, - supportsPublishPresence: false, - }, - }; - - // if we get initialized and users are available on the pr manager - const allAssignableUsers: Map = new Map(); - for (const pullRequestManager of this.pullRequestManager.folderManagers) { - const batch = pullRequestManager.getAllAssignableUsers(); - if (!batch) { - continue; - } - for (const user of batch) { - if (!allAssignableUsers.has(user.login)) { - allAssignableUsers.set(user.login, user); - } - } - } - if (allAssignableUsers.size > 0) { - this.notifySuggestedAccounts(Array.from(allAssignableUsers.values())); - } - - break; - default: - throw new Error(`type:${type} not supported`); - } - - return result; - } - - private async notifySuggestedAccounts(accounts: IAccount[]) { - let currentLoginUser: string | undefined; - try { - currentLoginUser = await this.getCurrentUserLogin(); - } catch (e) { - // If there are no GitHub repositories at the time of the above call, then we can get an error here. - // Since we don't care about the error and are just trying to notify accounts and not responding to user action, - // it is safe to ignore and leave currentLoginUser undefined. - } - if (currentLoginUser) { - // Note: only suggest if the current user is part of the aggregated mentionable users - if (accounts.findIndex(u => u.login === currentLoginUser) !== -1) { - this.notifySuggestedUsers( - accounts - .filter(u => u.email) - .map(u => { - return { - id: u.login, - displayName: u.name ? u.name : u.login, - email: u.email, - }; - }), - true, - ); - } - } - } - - private async getCurrentUserLogin(): Promise { - if (this.pullRequestManager.folderManagers.length === 0) { - return undefined; - } - const origin = await this.pullRequestManager.folderManagers[0]?.getOrigin(); - if (origin) { - const currentUser = origin.hub.currentUser ? await origin.hub.currentUser : undefined; - if (currentUser) { - return currentUser.login; - } - } - } - - private notify(type: string, body: any) { - this.onNotifiedEmitter.fire({ - type, - body, - }); - } - - private notifySuggestedUsers(contacts: Contact[], exclusive?: boolean) { - this.notify('suggestedUsers', { - contacts, - exclusive, - }); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { IAccount } from '../github/interface'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +/** + * The liveshare contact service contract + */ +interface ContactServiceProvider { + requestAsync(type: string, parameters: Object, cancellationToken?: vscode.CancellationToken): Promise; + + readonly onNotified: vscode.Event; +} + +interface NotifyContactServiceEventArgs { + type: string; + body?: any | undefined; +} + +/** + * The liveshare public contact contract + */ +interface Contact { + id: string; + displayName?: string | undefined; + email?: string | undefined; +} + +/** + * A contact service provider for liveshare that would suggest contacts based on the pull request manager + */ +export class GitHubContactServiceProvider implements ContactServiceProvider { + private readonly onNotifiedEmitter = new vscode.EventEmitter(); + + public onNotified: vscode.Event = this.onNotifiedEmitter.event; + + constructor(private readonly pullRequestManager: RepositoriesManager) { + pullRequestManager.folderManagers.forEach(folderManager => { + folderManager.onDidChangeAssignableUsers(e => { + this.notifySuggestedAccounts(e); + }); + }); + } + + public async requestAsync( + type: string, + _parameters: Object, + _cancellationToken?: vscode.CancellationToken, + ): Promise { + let result: Object | null = null; + + switch (type) { + case 'initialize': + result = { + description: 'Pullrequest', + capabilities: { + supportsDispose: false, + supportsInviteLink: false, + supportsPresence: false, + supportsContactPresenceRequest: false, + supportsPublishPresence: false, + }, + }; + + // if we get initialized and users are available on the pr manager + const allAssignableUsers: Map = new Map(); + for (const pullRequestManager of this.pullRequestManager.folderManagers) { + const batch = pullRequestManager.getAllAssignableUsers(); + if (!batch) { + continue; + } + for (const user of batch) { + if (!allAssignableUsers.has(user.login)) { + allAssignableUsers.set(user.login, user); + } + } + } + if (allAssignableUsers.size > 0) { + this.notifySuggestedAccounts(Array.from(allAssignableUsers.values())); + } + + break; + default: + throw new Error(`type:${type} not supported`); + } + + return result; + } + + private async notifySuggestedAccounts(accounts: IAccount[]) { + let currentLoginUser: string | undefined; + try { + currentLoginUser = await this.getCurrentUserLogin(); + } catch (e) { + // If there are no GitHub repositories at the time of the above call, then we can get an error here. + // Since we don't care about the error and are just trying to notify accounts and not responding to user action, + // it is safe to ignore and leave currentLoginUser undefined. + } + if (currentLoginUser) { + // Note: only suggest if the current user is part of the aggregated mentionable users + if (accounts.findIndex(u => u.login === currentLoginUser) !== -1) { + this.notifySuggestedUsers( + accounts + .filter(u => u.email) + .map(u => { + return { + id: u.login, + displayName: u.name ? u.name : u.login, + email: u.email, + }; + }), + true, + ); + } + } + } + + private async getCurrentUserLogin(): Promise { + if (this.pullRequestManager.folderManagers.length === 0) { + return undefined; + } + const origin = await this.pullRequestManager.folderManagers[0]?.getOrigin(); + if (origin) { + const currentUser = origin.hub.currentUser ? await origin.hub.currentUser : undefined; + if (currentUser) { + return currentUser.login; + } + } + } + + private notify(type: string, body: any) { + this.onNotifiedEmitter.fire({ + type, + body, + }); + } + + private notifySuggestedUsers(contacts: Contact[], exclusive?: boolean) { + this.notify('suggestedUsers', { + contacts, + exclusive, + }); + } +} diff --git a/src/gitProviders/api.ts b/src/gitProviders/api.ts index 353a957dff..ba76dad11a 100644 --- a/src/gitProviders/api.ts +++ b/src/gitProviders/api.ts @@ -1,26 +1,27 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { API } from '../api/api'; -import { CredentialStore } from '../github/credentials'; -import { BuiltinGitProvider } from './builtinGit'; -import { LiveShareManager } from './vsls'; - -export function registerLiveShareGitProvider(apiImpl: API): LiveShareManager { - const liveShareManager = new LiveShareManager(apiImpl); - return liveShareManager; -} - -export async function registerBuiltinGitProvider( - _credentialStore: CredentialStore, - apiImpl: API, -): Promise { - const builtInGitProvider = await BuiltinGitProvider.createProvider(); - if (builtInGitProvider) { - apiImpl.registerGitProvider(builtInGitProvider); - return builtInGitProvider; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { API } from '../api/api'; +import { CredentialStore } from '../github/credentials'; +import { BuiltinGitProvider } from './builtinGit'; +import { LiveShareManager } from './vsls'; + +export function registerLiveShareGitProvider(apiImpl: API): LiveShareManager { + const liveShareManager = new LiveShareManager(apiImpl); + return liveShareManager; +} + +export async function registerBuiltinGitProvider( + _credentialStore: CredentialStore, + apiImpl: API, +): Promise { + const builtInGitProvider = await BuiltinGitProvider.createProvider(); + if (builtInGitProvider) { + apiImpl.registerGitProvider(builtInGitProvider); + return builtInGitProvider; + } +} diff --git a/src/gitProviders/builtinGit.ts b/src/gitProviders/builtinGit.ts index 43603ec367..7934fbda9f 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -1,71 +1,72 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git'; -import { IGit, Repository } from '../api/api'; -import { commands } from '../common/executeCommands'; - -export class BuiltinGitProvider implements IGit, vscode.Disposable { - get repositories(): Repository[] { - return this._gitAPI.repositories as any[]; - } - - get state(): APIState { - return this._gitAPI.state; - } - - private _onDidOpenRepository = new vscode.EventEmitter(); - readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; - private _onDidCloseRepository = new vscode.EventEmitter(); - readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; - private _onDidChangeState = new vscode.EventEmitter(); - readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; - private _onDidPublish = new vscode.EventEmitter(); - readonly onDidPublish: vscode.Event = this._onDidPublish.event; - - private _gitAPI: GitAPI; - private _disposables: vscode.Disposable[]; - - private constructor(extension: vscode.Extension) { - const gitExtension = extension.exports; - - try { - this._gitAPI = gitExtension.getAPI(1); - } catch (e) { - // The git extension will throw if a git model cannot be found, i.e. if git is not installed. - commands.setContext('gitNotInstalled', true); - throw e; - } - - this._disposables = []; - this._disposables.push(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e as any))); - this._disposables.push(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e as any))); - this._disposables.push(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e))); - this._disposables.push(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e))); - } - - static async createProvider(): Promise { - const extension = vscode.extensions.getExtension('vscode.git'); - if (extension) { - await extension.activate(); - return new BuiltinGitProvider(extension); - } - return undefined; - } - - registerPostCommitCommandsProvider?(provider: any): vscode.Disposable { - if (this._gitAPI.registerPostCommitCommandsProvider) { - return this._gitAPI.registerPostCommitCommandsProvider(provider); - } - return { - dispose: () => { } - }; - } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git'; +import { IGit, Repository } from '../api/api'; +import { commands } from '../common/executeCommands'; + +export class BuiltinGitProvider implements IGit, vscode.Disposable { + get repositories(): Repository[] { + return this._gitAPI.repositories as any[]; + } + + get state(): APIState { + return this._gitAPI.state; + } + + private _onDidOpenRepository = new vscode.EventEmitter(); + readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; + private _onDidCloseRepository = new vscode.EventEmitter(); + readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; + private _onDidChangeState = new vscode.EventEmitter(); + readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + private _onDidPublish = new vscode.EventEmitter(); + readonly onDidPublish: vscode.Event = this._onDidPublish.event; + + private _gitAPI: GitAPI; + private _disposables: vscode.Disposable[]; + + private constructor(extension: vscode.Extension) { + const gitExtension = extension.exports; + + try { + this._gitAPI = gitExtension.getAPI(1); + } catch (e) { + // The git extension will throw if a git model cannot be found, i.e. if git is not installed. + commands.setContext('gitNotInstalled', true); + throw e; + } + + this._disposables = []; + this._disposables.push(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e as any))); + this._disposables.push(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e as any))); + this._disposables.push(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e))); + this._disposables.push(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e))); + } + + static async createProvider(): Promise { + const extension = vscode.extensions.getExtension('vscode.git'); + if (extension) { + await extension.activate(); + return new BuiltinGitProvider(extension); + } + return undefined; + } + + registerPostCommitCommandsProvider?(provider: any): vscode.Disposable { + if (this._gitAPI.registerPostCommitCommandsProvider) { + return this._gitAPI.registerPostCommitCommandsProvider(provider); + } + return { + dispose: () => { } + }; + } + + dispose() { + this._disposables.forEach(disposable => disposable.dispose()); + } +} diff --git a/src/gitProviders/gitCommands.ts b/src/gitProviders/gitCommands.ts index 3110f7b42e..3a1141529a 100644 --- a/src/gitProviders/gitCommands.ts +++ b/src/gitProviders/gitCommands.ts @@ -1,17 +1,18 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; - -export namespace git { - - export async function checkout(): Promise { - try { - await vscode.commands.executeCommand('git.checkout'); - } catch (e) { - await vscode.commands.executeCommand('remoteHub.switchToBranch'); - } - } - -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + + +export namespace git { + + export async function checkout(): Promise { + try { + await vscode.commands.executeCommand('git.checkout'); + } catch (e) { + await vscode.commands.executeCommand('remoteHub.switchToBranch'); + } + } + +} diff --git a/src/gitProviders/vsls.ts b/src/gitProviders/vsls.ts index 987aa7d95d..e398990cac 100644 --- a/src/gitProviders/vsls.ts +++ b/src/gitProviders/vsls.ts @@ -1,91 +1,92 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { LiveShare } from 'vsls/vscode.js'; -import { API } from '../api/api'; -import { VSLSGuest } from './vslsguest'; -import { VSLSHost } from './vslshost'; - -/** - * Should be removed once we fix the webpack bundling issue. - */ -async function getVSLSApi() { - const liveshareExtension = vscode.extensions.getExtension('ms-vsliveshare.vsliveshare'); - if (!liveshareExtension) { - // The extension is not installed. - return null; - } - const extensionApi = liveshareExtension.isActive ? liveshareExtension.exports : await liveshareExtension.activate(); - if (!extensionApi) { - // The extensibility API is not enabled. - return null; - } - const liveShareApiVersion = '0.3.967'; - // Support deprecated function name to preserve compatibility with older versions of VSLS. - if (!extensionApi.getApi) { - return extensionApi.getApiAsync(liveShareApiVersion); - } - return extensionApi.getApi(liveShareApiVersion); -} - -export class LiveShareManager implements vscode.Disposable { - private _liveShareAPI?: LiveShare; - private _host?: VSLSHost; - private _guest?: VSLSGuest; - private _localDisposables: vscode.Disposable[]; - private _globalDisposables: vscode.Disposable[]; - - constructor(private _api: API) { - this._localDisposables = []; - this._globalDisposables = []; - } - - /** - * return the liveshare api if available - */ - public async initialize(): Promise { - if (!this._liveShareAPI) { - this._liveShareAPI = await getVSLSApi(); - } - - if (!this._liveShareAPI) { - return; - } - - this._globalDisposables.push( - this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this), - ); - if (this._liveShareAPI!.session) { - this._onDidChangeSession(this._liveShareAPI!.session); - } - - return this._liveShareAPI; - } - - private async _onDidChangeSession(session: any) { - this._localDisposables.forEach(disposable => disposable.dispose()); - - if (session.role === 1 /* Role.Host */) { - this._host = new VSLSHost(this._liveShareAPI!, this._api); - this._localDisposables.push(this._host); - await this._host.initialize(); - return; - } - - if (session.role === 2 /* Role.Guest */) { - this._guest = new VSLSGuest(this._liveShareAPI!); - this._localDisposables.push(this._guest); - await this._guest.initialize(); - this._localDisposables.push(this._api.registerGitProvider(this._guest)); - } - } - - public dispose() { - this._liveShareAPI = undefined; - this._localDisposables.forEach(d => d.dispose()); - this._globalDisposables.forEach(d => d.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { LiveShare } from 'vsls/vscode.js'; +import { API } from '../api/api'; +import { VSLSGuest } from './vslsguest'; +import { VSLSHost } from './vslshost'; + +/** + * Should be removed once we fix the webpack bundling issue. + */ +async function getVSLSApi() { + const liveshareExtension = vscode.extensions.getExtension('ms-vsliveshare.vsliveshare'); + if (!liveshareExtension) { + // The extension is not installed. + return null; + } + const extensionApi = liveshareExtension.isActive ? liveshareExtension.exports : await liveshareExtension.activate(); + if (!extensionApi) { + // The extensibility API is not enabled. + return null; + } + const liveShareApiVersion = '0.3.967'; + // Support deprecated function name to preserve compatibility with older versions of VSLS. + if (!extensionApi.getApi) { + return extensionApi.getApiAsync(liveShareApiVersion); + } + return extensionApi.getApi(liveShareApiVersion); +} + +export class LiveShareManager implements vscode.Disposable { + private _liveShareAPI?: LiveShare; + private _host?: VSLSHost; + private _guest?: VSLSGuest; + private _localDisposables: vscode.Disposable[]; + private _globalDisposables: vscode.Disposable[]; + + constructor(private _api: API) { + this._localDisposables = []; + this._globalDisposables = []; + } + + /** + * return the liveshare api if available + */ + public async initialize(): Promise { + if (!this._liveShareAPI) { + this._liveShareAPI = await getVSLSApi(); + } + + if (!this._liveShareAPI) { + return; + } + + this._globalDisposables.push( + this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this), + ); + if (this._liveShareAPI!.session) { + this._onDidChangeSession(this._liveShareAPI!.session); + } + + return this._liveShareAPI; + } + + private async _onDidChangeSession(session: any) { + this._localDisposables.forEach(disposable => disposable.dispose()); + + if (session.role === 1 /* Role.Host */) { + this._host = new VSLSHost(this._liveShareAPI!, this._api); + this._localDisposables.push(this._host); + await this._host.initialize(); + return; + } + + if (session.role === 2 /* Role.Guest */) { + this._guest = new VSLSGuest(this._liveShareAPI!); + this._localDisposables.push(this._guest); + await this._guest.initialize(); + this._localDisposables.push(this._api.registerGitProvider(this._guest)); + } + } + + public dispose() { + this._liveShareAPI = undefined; + this._localDisposables.forEach(d => d.dispose()); + this._globalDisposables.forEach(d => d.dispose()); + } +} diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index 271db4abd6..394ed6e8d0 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -1,188 +1,189 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; -import { Branch, Change, Commit, Remote, RepositoryState, Submodule } from '../@types/git'; -import { IGit, Repository } from '../api/api'; -import { - VSLS_GIT_PR_SESSION_NAME, - VSLS_REPOSITORY_INITIALIZATION_NAME, - VSLS_REQUEST_NAME, - VSLS_STATE_CHANGE_NOTIFY_NAME, -} from '../constants'; - -export class VSLSGuest implements IGit, vscode.Disposable { - private _onDidOpenRepository = new vscode.EventEmitter(); - readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; - private _onDidCloseRepository = new vscode.EventEmitter(); - readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; - private _openRepositories: Repository[] = []; - get repositories(): Repository[] { - return this._openRepositories; - } - - private _sharedServiceProxy?: SharedServiceProxy; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare) { - this._disposables = []; - } - - public async initialize() { - this._sharedServiceProxy = (await this._liveShareAPI.getSharedService(VSLS_GIT_PR_SESSION_NAME)) || undefined; - - if (!this._sharedServiceProxy) { - return; - } - - if (this._sharedServiceProxy.isServiceAvailable) { - await this._refreshWorkspaces(true); - } - this._disposables.push( - this._sharedServiceProxy.onDidChangeIsServiceAvailable(async e => { - await this._refreshWorkspaces(e); - }), - ); - this._disposables.push( - vscode.workspace.onDidChangeWorkspaceFolders(this._onDidChangeWorkspaceFolders.bind(this)), - ); - } - - private async _onDidChangeWorkspaceFolders(e: vscode.WorkspaceFoldersChangeEvent) { - e.added.forEach(async folder => { - if ( - folder.uri.scheme === 'vsls' && - this._sharedServiceProxy && - this._sharedServiceProxy.isServiceAvailable - ) { - await this.openVSLSRepository(folder); - } - }); - - e.removed.forEach(async folder => { - if ( - folder.uri.scheme === 'vsls' && - this._sharedServiceProxy && - this._sharedServiceProxy.isServiceAvailable - ) { - await this.closeVSLSRepository(folder); - } - }); - } - - private async _refreshWorkspaces(available: boolean) { - if (vscode.workspace.workspaceFolders) { - vscode.workspace.workspaceFolders.forEach(async folder => { - if (folder.uri.scheme === 'vsls') { - if (available) { - await this.openVSLSRepository(folder); - } else { - await this.closeVSLSRepository(folder); - } - } - }); - } - } - - public async openVSLSRepository(folder: vscode.WorkspaceFolder): Promise { - const existingRepository = this.getRepository(folder); - if (existingRepository) { - return; - } - const liveShareRepository = new LiveShareRepository(folder, this._sharedServiceProxy!); - const repositoryProxyHandler = new LiveShareRepositoryProxyHandler(); - const repository = new Proxy(liveShareRepository, repositoryProxyHandler); - await repository.initialize(); - this.openRepository(repository); - } - - public async closeVSLSRepository(folder: vscode.WorkspaceFolder): Promise { - const existingRepository = this.getRepository(folder); - if (!existingRepository) { - return; - } - - this.closeRepository(existingRepository); - } - - public openRepository(repository: Repository) { - this._openRepositories.push(repository); - this._onDidOpenRepository.fire(repository); - } - - public closeRepository(repository: Repository) { - this._openRepositories = this._openRepositories.filter(e => e !== repository); - this._onDidCloseRepository.fire(repository); - } - - public getRepository(folder: vscode.WorkspaceFolder): Repository { - return this._openRepositories.filter(repository => (repository as any).workspaceFolder === folder)[0]; - } - - public dispose() { - this._sharedServiceProxy = undefined; - this._disposables.forEach(d => d.dispose()); - this._disposables = []; - } -} - -class LiveShareRepositoryProxyHandler { - constructor() { } - - get(obj: any, prop: any) { - if (prop in obj) { - return obj[prop]; - } - - return function (...args: any[]) { - return obj.proxy.request(VSLS_REQUEST_NAME, [prop, obj.workspaceFolder.uri.toString(), ...args]); - }; - } -} - -class LiveShareRepositoryState implements RepositoryState { - HEAD: Branch | undefined; - remotes: Remote[]; - submodules: Submodule[] = []; - rebaseCommit: Commit | undefined; - mergeChanges: Change[] = []; - indexChanges: Change[] = []; - workingTreeChanges: Change[] = []; - _onDidChange = new vscode.EventEmitter(); - onDidChange = this._onDidChange.event; - - constructor(state: RepositoryState) { - this.HEAD = state.HEAD; - this.remotes = state.remotes; - } - - public update(state: RepositoryState) { - this.HEAD = state.HEAD; - this.remotes = state.remotes; - - this._onDidChange.fire(); - } -} - -class LiveShareRepository { - rootUri: vscode.Uri | undefined; - state: LiveShareRepositoryState | undefined; - - constructor(public workspaceFolder: vscode.WorkspaceFolder, public proxy: SharedServiceProxy) { } - - public async initialize() { - const result = await this.proxy.request(VSLS_REQUEST_NAME, [ - VSLS_REPOSITORY_INITIALIZATION_NAME, - this.workspaceFolder.uri.toString(), - ]); - this.state = new LiveShareRepositoryState(result); - this.rootUri = vscode.Uri.parse(result.rootUri); - this.proxy.onNotify(VSLS_STATE_CHANGE_NOTIFY_NAME, this._notifyHandler.bind(this)); - } - - private _notifyHandler(args: any) { - this.state?.update(args); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; +import { Branch, Change, Commit, Remote, RepositoryState, Submodule } from '../@types/git'; +import { IGit, Repository } from '../api/api'; +import { + VSLS_GIT_PR_SESSION_NAME, + VSLS_REPOSITORY_INITIALIZATION_NAME, + VSLS_REQUEST_NAME, + VSLS_STATE_CHANGE_NOTIFY_NAME, +} from '../constants'; + +export class VSLSGuest implements IGit, vscode.Disposable { + private _onDidOpenRepository = new vscode.EventEmitter(); + readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; + private _onDidCloseRepository = new vscode.EventEmitter(); + readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; + private _openRepositories: Repository[] = []; + get repositories(): Repository[] { + return this._openRepositories; + } + + private _sharedServiceProxy?: SharedServiceProxy; + private _disposables: vscode.Disposable[]; + constructor(private _liveShareAPI: LiveShare) { + this._disposables = []; + } + + public async initialize() { + this._sharedServiceProxy = (await this._liveShareAPI.getSharedService(VSLS_GIT_PR_SESSION_NAME)) || undefined; + + if (!this._sharedServiceProxy) { + return; + } + + if (this._sharedServiceProxy.isServiceAvailable) { + await this._refreshWorkspaces(true); + } + this._disposables.push( + this._sharedServiceProxy.onDidChangeIsServiceAvailable(async e => { + await this._refreshWorkspaces(e); + }), + ); + this._disposables.push( + vscode.workspace.onDidChangeWorkspaceFolders(this._onDidChangeWorkspaceFolders.bind(this)), + ); + } + + private async _onDidChangeWorkspaceFolders(e: vscode.WorkspaceFoldersChangeEvent) { + e.added.forEach(async folder => { + if ( + folder.uri.scheme === 'vsls' && + this._sharedServiceProxy && + this._sharedServiceProxy.isServiceAvailable + ) { + await this.openVSLSRepository(folder); + } + }); + + e.removed.forEach(async folder => { + if ( + folder.uri.scheme === 'vsls' && + this._sharedServiceProxy && + this._sharedServiceProxy.isServiceAvailable + ) { + await this.closeVSLSRepository(folder); + } + }); + } + + private async _refreshWorkspaces(available: boolean) { + if (vscode.workspace.workspaceFolders) { + vscode.workspace.workspaceFolders.forEach(async folder => { + if (folder.uri.scheme === 'vsls') { + if (available) { + await this.openVSLSRepository(folder); + } else { + await this.closeVSLSRepository(folder); + } + } + }); + } + } + + public async openVSLSRepository(folder: vscode.WorkspaceFolder): Promise { + const existingRepository = this.getRepository(folder); + if (existingRepository) { + return; + } + const liveShareRepository = new LiveShareRepository(folder, this._sharedServiceProxy!); + const repositoryProxyHandler = new LiveShareRepositoryProxyHandler(); + const repository = new Proxy(liveShareRepository, repositoryProxyHandler); + await repository.initialize(); + this.openRepository(repository); + } + + public async closeVSLSRepository(folder: vscode.WorkspaceFolder): Promise { + const existingRepository = this.getRepository(folder); + if (!existingRepository) { + return; + } + + this.closeRepository(existingRepository); + } + + public openRepository(repository: Repository) { + this._openRepositories.push(repository); + this._onDidOpenRepository.fire(repository); + } + + public closeRepository(repository: Repository) { + this._openRepositories = this._openRepositories.filter(e => e !== repository); + this._onDidCloseRepository.fire(repository); + } + + public getRepository(folder: vscode.WorkspaceFolder): Repository { + return this._openRepositories.filter(repository => (repository as any).workspaceFolder === folder)[0]; + } + + public dispose() { + this._sharedServiceProxy = undefined; + this._disposables.forEach(d => d.dispose()); + this._disposables = []; + } +} + +class LiveShareRepositoryProxyHandler { + constructor() { } + + get(obj: any, prop: any) { + if (prop in obj) { + return obj[prop]; + } + + return function (...args: any[]) { + return obj.proxy.request(VSLS_REQUEST_NAME, [prop, obj.workspaceFolder.uri.toString(), ...args]); + }; + } +} + +class LiveShareRepositoryState implements RepositoryState { + HEAD: Branch | undefined; + remotes: Remote[]; + submodules: Submodule[] = []; + rebaseCommit: Commit | undefined; + mergeChanges: Change[] = []; + indexChanges: Change[] = []; + workingTreeChanges: Change[] = []; + _onDidChange = new vscode.EventEmitter(); + onDidChange = this._onDidChange.event; + + constructor(state: RepositoryState) { + this.HEAD = state.HEAD; + this.remotes = state.remotes; + } + + public update(state: RepositoryState) { + this.HEAD = state.HEAD; + this.remotes = state.remotes; + + this._onDidChange.fire(); + } +} + +class LiveShareRepository { + rootUri: vscode.Uri | undefined; + state: LiveShareRepositoryState | undefined; + + constructor(public workspaceFolder: vscode.WorkspaceFolder, public proxy: SharedServiceProxy) { } + + public async initialize() { + const result = await this.proxy.request(VSLS_REQUEST_NAME, [ + VSLS_REPOSITORY_INITIALIZATION_NAME, + this.workspaceFolder.uri.toString(), + ]); + this.state = new LiveShareRepositoryState(result); + this.rootUri = vscode.Uri.parse(result.rootUri); + this.proxy.onNotify(VSLS_STATE_CHANGE_NOTIFY_NAME, this._notifyHandler.bind(this)); + } + + private _notifyHandler(args: any) { + this.state?.update(args); + } +} diff --git a/src/gitProviders/vslshost.ts b/src/gitProviders/vslshost.ts index 1dc86ea9f3..ffbda4e193 100644 --- a/src/gitProviders/vslshost.ts +++ b/src/gitProviders/vslshost.ts @@ -1,86 +1,87 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { LiveShare, SharedService } from 'vsls/vscode.js'; -import { API } from '../api/api'; -import { - VSLS_GIT_PR_SESSION_NAME, - VSLS_REPOSITORY_INITIALIZATION_NAME, - VSLS_REQUEST_NAME, - VSLS_STATE_CHANGE_NOTIFY_NAME, -} from '../constants'; - -export class VSLSHost implements vscode.Disposable { - private _sharedService?: SharedService; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare, private _api: API) { - this._disposables = []; - } - - public async initialize() { - this._sharedService = (await this._liveShareAPI!.shareService(VSLS_GIT_PR_SESSION_NAME)) || undefined; - - if (this._sharedService) { - this._sharedService.onRequest(VSLS_REQUEST_NAME, this._gitHandler.bind(this)); - } - } - - private async _gitHandler(args: any[]) { - const type = args[0]; - const workspaceFolderPath = args[1]; - const workspaceFolderUri = vscode.Uri.parse(workspaceFolderPath); - const localWorkSpaceFolderUri = this._liveShareAPI.convertSharedUriToLocal(workspaceFolderUri); - const gitProvider = this._api.getGitProvider(localWorkSpaceFolderUri); - - if (!gitProvider) { - return; - } - - const localRepository: any = gitProvider.repositories.filter( - repository => repository.rootUri.toString() === localWorkSpaceFolderUri.toString(), - )[0]; - if (localRepository) { - const commandArgs = args.slice(2); - if (type === VSLS_REPOSITORY_INITIALIZATION_NAME) { - this._disposables.push( - localRepository.state.onDidChange(_ => { - this._sharedService!.notify(VSLS_STATE_CHANGE_NOTIFY_NAME, { - HEAD: localRepository.state.HEAD, - remotes: localRepository.state.remotes, - refs: localRepository.state.refs, - }); - }), - ); - return { - HEAD: localRepository.state.HEAD, - remotes: localRepository.state.remotes, - refs: localRepository.state.refs, - rootUri: workspaceFolderUri.toString(), // file: --> vsls:/ - }; - } - - if (type === 'show') { - const path = commandArgs[1]; - const vslsFileUri = workspaceFolderUri.with({ path: path }); - const localFileUri = this._liveShareAPI.convertSharedUriToLocal(vslsFileUri); - commandArgs[1] = localFileUri.fsPath; - - return localRepository[type](...commandArgs); - } - - if (localRepository[type]) { - return localRepository[type](...commandArgs); - } - } else { - return null; - } - } - public dispose() { - this._disposables.forEach(d => d.dispose()); - this._sharedService = undefined; - this._disposables = []; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { LiveShare, SharedService } from 'vsls/vscode.js'; +import { API } from '../api/api'; +import { + VSLS_GIT_PR_SESSION_NAME, + VSLS_REPOSITORY_INITIALIZATION_NAME, + VSLS_REQUEST_NAME, + VSLS_STATE_CHANGE_NOTIFY_NAME, +} from '../constants'; + +export class VSLSHost implements vscode.Disposable { + private _sharedService?: SharedService; + private _disposables: vscode.Disposable[]; + constructor(private _liveShareAPI: LiveShare, private _api: API) { + this._disposables = []; + } + + public async initialize() { + this._sharedService = (await this._liveShareAPI!.shareService(VSLS_GIT_PR_SESSION_NAME)) || undefined; + + if (this._sharedService) { + this._sharedService.onRequest(VSLS_REQUEST_NAME, this._gitHandler.bind(this)); + } + } + + private async _gitHandler(args: any[]) { + const type = args[0]; + const workspaceFolderPath = args[1]; + const workspaceFolderUri = vscode.Uri.parse(workspaceFolderPath); + const localWorkSpaceFolderUri = this._liveShareAPI.convertSharedUriToLocal(workspaceFolderUri); + const gitProvider = this._api.getGitProvider(localWorkSpaceFolderUri); + + if (!gitProvider) { + return; + } + + const localRepository: any = gitProvider.repositories.filter( + repository => repository.rootUri.toString() === localWorkSpaceFolderUri.toString(), + )[0]; + if (localRepository) { + const commandArgs = args.slice(2); + if (type === VSLS_REPOSITORY_INITIALIZATION_NAME) { + this._disposables.push( + localRepository.state.onDidChange(_ => { + this._sharedService!.notify(VSLS_STATE_CHANGE_NOTIFY_NAME, { + HEAD: localRepository.state.HEAD, + remotes: localRepository.state.remotes, + refs: localRepository.state.refs, + }); + }), + ); + return { + HEAD: localRepository.state.HEAD, + remotes: localRepository.state.remotes, + refs: localRepository.state.refs, + rootUri: workspaceFolderUri.toString(), // file: --> vsls:/ + }; + } + + if (type === 'show') { + const path = commandArgs[1]; + const vslsFileUri = workspaceFolderUri.with({ path: path }); + const localFileUri = this._liveShareAPI.convertSharedUriToLocal(vslsFileUri); + commandArgs[1] = localFileUri.fsPath; + + return localRepository[type](...commandArgs); + } + + if (localRepository[type]) { + return localRepository[type](...commandArgs); + } + } else { + return null; + } + } + public dispose() { + this._disposables.forEach(d => d.dispose()); + this._sharedService = undefined; + this._disposables = []; + } +} diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index ad02943937..6d4f6dc419 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -1,490 +1,491 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { dispose, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { ReviewManager } from '../view/reviewManager'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GithubItemStateEnum, IAccount, isTeam, reviewerId, ReviewEvent, ReviewState } from './interface'; -import { PullRequestModel } from './pullRequestModel'; -import { getDefaultMergeMethod } from './pullRequestOverview'; -import { PullRequestView } from './pullRequestOverviewCommon'; -import { isInCodespaces, parseReviewers } from './utils'; -import { PullRequest, ReviewType } from './views'; - -export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { - public readonly viewType = 'github:activePullRequest'; - private _existingReviewers: ReviewState[] = []; - private _prChangeListener: vscode.Disposable | undefined; - - constructor( - extensionUri: vscode.Uri, - private readonly _folderRepositoryManager: FolderRepositoryManager, - private readonly _reviewManager: ReviewManager, - private _item: PullRequestModel, - ) { - super(extensionUri); - - onDidUpdatePR( - pr => { - if (pr) { - this._item.update(pr); - } - - this._postMessage({ - command: 'update-state', - state: this._item.state, - }); - }, - null, - this._disposables, - ); - - this._disposables.push(this._folderRepositoryManager.onDidMergePullRequest(_ => { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); - })); - - this._disposables.push(vscode.commands.registerCommand('review.approve', (e: { body: string }) => this.approvePullRequestCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.comment', (e: { body: string }) => this.submitReviewCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.requestChanges', (e: { body: string }) => this.requestChangesCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.approveOnDotCom', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - })); - this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotCom', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - })); - } - - public resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ) { - super.resolveWebviewView(webviewView, _context, _token); - webviewView.webview.html = this._getHtmlForWebview(); - - this.updatePullRequest(this._item); - } - - protected async _onDidReceiveMessage(message: IRequestMessage) { - const result = await super._onDidReceiveMessage(message); - if (result !== this.MESSAGE_UNHANDLED) { - return; - } - - switch (message.command) { - case 'alert': - vscode.window.showErrorMessage(message.args); - return; - case 'pr.close': - return this.close(message); - case 'pr.comment': - return this.createComment(message); - case 'pr.merge': - return this.mergePullRequest(message); - case 'pr.open-create': - return this.create(); - case 'pr.deleteBranch': - return this.deleteBranch(message); - case 'pr.readyForReview': - return this.setReadyForReview(message); - case 'pr.approve': - return this.approvePullRequestMessage(message); - case 'pr.request-changes': - return this.requestChangesMessage(message); - case 'pr.submit': - return this.submitReviewMessage(message); - case 'pr.openOnGitHub': - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - case 'pr.checkout-default-branch': - return this.checkoutDefaultBranch(message); - case 'pr.re-request-review': - return this.reRequestReview(message); - } - } - - private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); - } - } - - private reRequestReview(message: IRequestMessage): void { - let targetReviewer: ReviewState | undefined; - const userReviewers: string[] = []; - const teamReviewers: string[] = []; - - for (const reviewer of this._existingReviewers) { - let id: string | undefined; - let reviewerArray: string[] | undefined; - if (reviewer && isTeam(reviewer.reviewer)) { - id = reviewer.reviewer.id; - reviewerArray = teamReviewers; - } else if (reviewer && !isTeam(reviewer.reviewer)) { - id = reviewer.reviewer.id; - reviewerArray = userReviewers; - } - if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { - reviewerArray.push(id); - if (id === message.args) { - targetReviewer = reviewer; - } - } - } - this._item.requestReview(userReviewers, teamReviewers).then(() => { - if (targetReviewer) { - targetReviewer.state = 'REQUESTED'; - } - this._replyMessage(message, { - reviewers: this._existingReviewers, - }); - }); - } - - public async refresh(): Promise { - await this.updatePullRequest(this._item); - } - - private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { - const review = reviewers.find(r => reviewerId(r.reviewer) === currentUser.login); - // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user - return review?.state; - } - - private _prDisposables: vscode.Disposable[] | undefined = undefined; - private registerPrSpecificListeners(pullRequestModel: PullRequestModel) { - if (this._prDisposables !== undefined) { - dispose(this._prDisposables); - } - this._prDisposables = []; - this._prDisposables.push(pullRequestModel.onDidInvalidate(() => this.updatePullRequest(pullRequestModel))); - this._prDisposables.push(pullRequestModel.onDidChangePendingReviewState(() => this.updatePullRequest(pullRequestModel))); - } - - private _updatePendingVisibility: vscode.Disposable | undefined = undefined; - public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - if (this._view && !this._view.visible) { - this._updatePendingVisibility?.dispose(); - this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { - this.updatePullRequest(pullRequestModel); - this._updatePendingVisibility?.dispose(); - }); - } - - if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { - this.registerPrSpecificListeners(pullRequestModel); - } - this._item = pullRequestModel; - return Promise.all([ - this._folderRepositoryManager.resolvePullRequest( - pullRequestModel.remote.owner, - pullRequestModel.remote.repositoryName, - pullRequestModel.number, - ), - this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), - pullRequestModel.getTimelineEvents(), - pullRequestModel.getReviewRequests(), - this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), - this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), - pullRequestModel.canEdit(), - pullRequestModel.validateDraftMode() - ]) - .then(result => { - const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = result; - if (!pullRequest) { - throw new Error( - `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, - ); - } - - this._item = pullRequest; - if (!this._view) { - // If the there is no PR webview, then there is nothing else to update. - return; - } - - try { - this._view.title = `${pullRequest.title} #${pullRequestModel.number.toString()}`; - } catch (e) { - // If we ry to set the title of the webview too early it will throw an error. - } - - const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); - const hasWritePermission = repositoryAccess!.hasWritePermission; - const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || viewerCanEdit; - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); - this._existingReviewers = parseReviewers( - requestedReviewers ?? [], - timelineEvents ?? [], - pullRequest.author, - ); - - const isCrossRepository = - pullRequest.base && - pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - - const continueOnGitHub = !!(isCrossRepository && isInCodespaces()); - const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); - - const context: Partial = { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - email: pullRequest.author.email, - id: pullRequest.author.id - }, - state: pullRequest.state, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - canEdit: canEdit, - hasWritePermission, - mergeable: pullRequest.item.mergeable, - isDraft: pullRequest.isDraft, - status: null, - reviewRequirement: null, - events: [], - mergeMethodsAvailability, - defaultMergeMethod, - repositoryDefaultBranch: defaultBranch, - isIssue: false, - isAuthor: currentUser.login === pullRequest.author.login, - reviewers: this._existingReviewers, - continueOnGitHub, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, - isEnterprise: pullRequest.githubRepository.remote.isEnterprise, - hasReviewDraft, - currentUserReviewState: reviewState - }; - - this._postMessage({ - command: 'pr.initialize', - pullrequest: context, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); - }); - } - - private close(message: IRequestMessage): void { - vscode.commands.executeCommand('pr.close', this._item, message.args).then(comment => { - if (comment) { - this._replyMessage(message, { - value: comment, - }); - } - }); - } - - private create() { - this._reviewManager.createPullRequest(); - } - - private createComment(message: IRequestMessage) { - this._item.createIssueComment(message.args).then(comment => { - this._replyMessage(message, { - value: comment, - }); - }); - } - - private updateReviewers(review?: CommonReviewEvent): void { - if (review) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === reviewerId(reviewer.reviewer), - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } - } - } - - private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { - const submittingMessage = { - command: 'pr.submitting-review', - lastReviewType: reviewType - }; - this._postMessage(submittingMessage); - try { - const review = await action(context.body); - this.updateReviewers(review); - const reviewMessage = { - command: 'pr.append-review', - review, - reviewers: this._existingReviewers - }; - await this._postMessage(reviewMessage); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(undefined, `${formatError(e)}`); - } finally { - this._postMessage({ command: 'pr.append-review' }); - } - } - - private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { - try { - const review = await action(message.args); - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); - } - } - - private approvePullRequest(body: string): Promise { - return this._item.approve(this._folderRepositoryManager.repository, body); - } - - private approvePullRequestMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); - } - - private approvePullRequestCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); - } - - private requestChanges(body: string): Promise { - return this._item.requestChanges(body); - } - - private requestChangesCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); - } - - private requestChangesMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.requestChanges(body)); - } - - private submitReview(body: string): Promise { - return this._item.submitReview(ReviewEvent.Comment, body); - } - - private submitReviewCommand(context: { body: string }) { - return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); - } - - private submitReviewMessage(message: IRequestMessage) { - return this.doReviewMessage(message, (body) => this.submitReview(body)); - } - - private async deleteBranch(message: IRequestMessage) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); - if (result.isReply) { - this._replyMessage(message, result.message); - } else { - this._postMessage(result.message); - } - } - - private setReadyForReview(message: IRequestMessage>): void { - this._item - .setReadyForReview() - .then(isDraft => { - vscode.commands.executeCommand('pr.refreshList'); - - this._replyMessage(message, { isDraft }); - }) - .catch(e => { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to set PR ready for review. {0}', formatError(e))); - this._throwError(message, {}); - }); - } - - private async mergePullRequest( - message: IRequestMessage<{ title: string; description: string; method: 'merge' | 'squash' | 'rebase' }>, - ): Promise { - const { title, description, method } = message.args; - const yes = vscode.l10n.t('Yes'); - const confirmation = await vscode.window.showInformationMessage( - vscode.l10n.t('Merge this pull request?'), - { modal: true }, - yes, - ); - if (confirmation !== yes) { - this._replyMessage(message, { state: GithubItemStateEnum.Open }); - return; - } - - this._folderRepositoryManager - .mergePullRequest(this._item, title, description, method) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); - - if (!result.merged) { - vscode.window.showErrorMessage(vscode.l10n.t('Merging PR failed: {0}', result?.message ?? '')); - } - - this._replyMessage(message, { - state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request. {0}', formatError(e))); - this._throwError(message, {}); - }); - } - - private _getHtmlForWebview() { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-open-pr-view.js'); - - return ` - - - - - - - Active Pull Request - - -
- - -`; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; +import { IComment } from '../common/comment'; +import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; +import { dispose, formatError } from '../common/utils'; +import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; +import { ReviewManager } from '../view/reviewManager'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GithubItemStateEnum, IAccount, isTeam, reviewerId, ReviewEvent, ReviewState } from './interface'; +import { PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { PullRequestView } from './pullRequestOverviewCommon'; +import { isInCodespaces, parseReviewers } from './utils'; +import { PullRequest, ReviewType } from './views'; + +export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { + public readonly viewType = 'github:activePullRequest'; + private _existingReviewers: ReviewState[] = []; + private _prChangeListener: vscode.Disposable | undefined; + + constructor( + extensionUri: vscode.Uri, + private readonly _folderRepositoryManager: FolderRepositoryManager, + private readonly _reviewManager: ReviewManager, + private _item: PullRequestModel, + ) { + super(extensionUri); + + onDidUpdatePR( + pr => { + if (pr) { + this._item.update(pr); + } + + this._postMessage({ + command: 'update-state', + state: this._item.state, + }); + }, + null, + this._disposables, + ); + + this._disposables.push(this._folderRepositoryManager.onDidMergePullRequest(_ => { + this._postMessage({ + command: 'update-state', + state: GithubItemStateEnum.Merged, + }); + })); + + this._disposables.push(vscode.commands.registerCommand('review.approve', (e: { body: string }) => this.approvePullRequestCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.comment', (e: { body: string }) => this.submitReviewCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.requestChanges', (e: { body: string }) => this.requestChangesCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.approveOnDotCom', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotCom', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + super.resolveWebviewView(webviewView, _context, _token); + webviewView.webview.html = this._getHtmlForWebview(); + + this.updatePullRequest(this._item); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'alert': + vscode.window.showErrorMessage(message.args); + return; + case 'pr.close': + return this.close(message); + case 'pr.comment': + return this.createComment(message); + case 'pr.merge': + return this.mergePullRequest(message); + case 'pr.open-create': + return this.create(); + case 'pr.deleteBranch': + return this.deleteBranch(message); + case 'pr.readyForReview': + return this.setReadyForReview(message); + case 'pr.approve': + return this.approvePullRequestMessage(message); + case 'pr.request-changes': + return this.requestChangesMessage(message); + case 'pr.submit': + return this.submitReviewMessage(message); + case 'pr.openOnGitHub': + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + case 'pr.checkout-default-branch': + return this.checkoutDefaultBranch(message); + case 'pr.re-request-review': + return this.reRequestReview(message); + } + } + + private async checkoutDefaultBranch(message: IRequestMessage): Promise { + try { + const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); + const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; + await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); + if (prBranch) { + await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); + } + } finally { + // Complete webview promise so that button becomes enabled again + this._replyMessage(message, {}); + } + } + + private reRequestReview(message: IRequestMessage): void { + let targetReviewer: ReviewState | undefined; + const userReviewers: string[] = []; + const teamReviewers: string[] = []; + + for (const reviewer of this._existingReviewers) { + let id: string | undefined; + let reviewerArray: string[] | undefined; + if (reviewer && isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = teamReviewers; + } else if (reviewer && !isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = userReviewers; + } + if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { + reviewerArray.push(id); + if (id === message.args) { + targetReviewer = reviewer; + } + } + } + this._item.requestReview(userReviewers, teamReviewers).then(() => { + if (targetReviewer) { + targetReviewer.state = 'REQUESTED'; + } + this._replyMessage(message, { + reviewers: this._existingReviewers, + }); + }); + } + + public async refresh(): Promise { + await this.updatePullRequest(this._item); + } + + private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { + const review = reviewers.find(r => reviewerId(r.reviewer) === currentUser.login); + // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user + return review?.state; + } + + private _prDisposables: vscode.Disposable[] | undefined = undefined; + private registerPrSpecificListeners(pullRequestModel: PullRequestModel) { + if (this._prDisposables !== undefined) { + dispose(this._prDisposables); + } + this._prDisposables = []; + this._prDisposables.push(pullRequestModel.onDidInvalidate(() => this.updatePullRequest(pullRequestModel))); + this._prDisposables.push(pullRequestModel.onDidChangePendingReviewState(() => this.updatePullRequest(pullRequestModel))); + } + + private _updatePendingVisibility: vscode.Disposable | undefined = undefined; + public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { + if (this._view && !this._view.visible) { + this._updatePendingVisibility?.dispose(); + this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { + this.updatePullRequest(pullRequestModel); + this._updatePendingVisibility?.dispose(); + }); + } + + if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { + this.registerPrSpecificListeners(pullRequestModel); + } + this._item = pullRequestModel; + return Promise.all([ + this._folderRepositoryManager.resolvePullRequest( + pullRequestModel.remote.owner, + pullRequestModel.remote.repositoryName, + pullRequestModel.number, + ), + this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), + pullRequestModel.getTimelineEvents(), + pullRequestModel.getReviewRequests(), + this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), + this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), + pullRequestModel.canEdit(), + pullRequestModel.validateDraftMode() + ]) + .then(result => { + const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = result; + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); + } + + this._item = pullRequest; + if (!this._view) { + // If the there is no PR webview, then there is nothing else to update. + return; + } + + try { + this._view.title = `${pullRequest.title} #${pullRequestModel.number.toString()}`; + } catch (e) { + // If we ry to set the title of the webview too early it will throw an error. + } + + const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); + const hasWritePermission = repositoryAccess!.hasWritePermission; + const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; + const canEdit = hasWritePermission || viewerCanEdit; + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); + this._existingReviewers = parseReviewers( + requestedReviewers ?? [], + timelineEvents ?? [], + pullRequest.author, + ); + + const isCrossRepository = + pullRequest.base && + pullRequest.head && + !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + + const continueOnGitHub = !!(isCrossRepository && isInCodespaces()); + const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); + + const context: Partial = { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url, + createdAt: pullRequest.createdAt, + body: pullRequest.body, + bodyHTML: pullRequest.bodyHTML, + labels: pullRequest.item.labels, + author: { + login: pullRequest.author.login, + name: pullRequest.author.name, + avatarUrl: pullRequest.userAvatar, + url: pullRequest.author.url, + email: pullRequest.author.email, + id: pullRequest.author.id + }, + state: pullRequest.state, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: pullRequest.base.label, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head?.label ?? '', + canEdit: canEdit, + hasWritePermission, + mergeable: pullRequest.item.mergeable, + isDraft: pullRequest.isDraft, + status: null, + reviewRequirement: null, + events: [], + mergeMethodsAvailability, + defaultMergeMethod, + repositoryDefaultBranch: defaultBranch, + isIssue: false, + isAuthor: currentUser.login === pullRequest.author.login, + reviewers: this._existingReviewers, + continueOnGitHub, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + isEnterprise: pullRequest.githubRepository.remote.isEnterprise, + hasReviewDraft, + currentUserReviewState: reviewState + }; + + this._postMessage({ + command: 'pr.initialize', + pullrequest: context, + }); + }) + .catch(e => { + vscode.window.showErrorMessage(formatError(e)); + }); + } + + private close(message: IRequestMessage): void { + vscode.commands.executeCommand('pr.close', this._item, message.args).then(comment => { + if (comment) { + this._replyMessage(message, { + value: comment, + }); + } + }); + } + + private create() { + this._reviewManager.createPullRequest(); + } + + private createComment(message: IRequestMessage) { + this._item.createIssueComment(message.args).then(comment => { + this._replyMessage(message, { + value: comment, + }); + }); + } + + private updateReviewers(review?: CommonReviewEvent): void { + if (review) { + const existingReviewer = this._existingReviewers.find( + reviewer => review.user.login === reviewerId(reviewer.reviewer), + ); + if (existingReviewer) { + existingReviewer.state = review.state; + } else { + this._existingReviewers.push({ + reviewer: review.user, + state: review.state, + }); + } + } + } + + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { + const submittingMessage = { + command: 'pr.submitting-review', + lastReviewType: reviewType + }; + this._postMessage(submittingMessage); + try { + const review = await action(context.body); + this.updateReviewers(review); + const reviewMessage = { + command: 'pr.append-review', + review, + reviewers: this._existingReviewers + }; + await this._postMessage(reviewMessage); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(undefined, `${formatError(e)}`); + } finally { + this._postMessage({ command: 'pr.append-review' }); + } + } + + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + try { + const review = await action(message.args); + this.updateReviewers(review); + this._replyMessage(message, { + review: review, + reviewers: this._existingReviewers, + }); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } + } + + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); + } + + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + } + + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } + + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEvent.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); + } + + private async deleteBranch(message: IRequestMessage) { + const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + if (result.isReply) { + this._replyMessage(message, result.message); + } else { + this._postMessage(result.message); + } + } + + private setReadyForReview(message: IRequestMessage>): void { + this._item + .setReadyForReview() + .then(isDraft => { + vscode.commands.executeCommand('pr.refreshList'); + + this._replyMessage(message, { isDraft }); + }) + .catch(e => { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to set PR ready for review. {0}', formatError(e))); + this._throwError(message, {}); + }); + } + + private async mergePullRequest( + message: IRequestMessage<{ title: string; description: string; method: 'merge' | 'squash' | 'rebase' }>, + ): Promise { + const { title, description, method } = message.args; + const yes = vscode.l10n.t('Yes'); + const confirmation = await vscode.window.showInformationMessage( + vscode.l10n.t('Merge this pull request?'), + { modal: true }, + yes, + ); + if (confirmation !== yes) { + this._replyMessage(message, { state: GithubItemStateEnum.Open }); + return; + } + + this._folderRepositoryManager + .mergePullRequest(this._item, title, description, method) + .then(result => { + vscode.commands.executeCommand('pr.refreshList'); + + if (!result.merged) { + vscode.window.showErrorMessage(vscode.l10n.t('Merging PR failed: {0}', result?.message ?? '')); + } + + this._replyMessage(message, { + state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, + }); + }) + .catch(e => { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request. {0}', formatError(e))); + this._throwError(message, {}); + }); + } + + private _getHtmlForWebview() { + const nonce = getNonce(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-open-pr-view.js'); + + return ` + + + + + + + Active Pull Request + + +
+ + +`; + } +} diff --git a/src/github/common.ts b/src/github/common.ts index 9a6a2fffe0..cde0f7f032 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -1,62 +1,63 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as OctokitRest from '@octokit/rest'; -import { Endpoints } from '@octokit/types'; - -export namespace OctokitCommon { - export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; - export type IssuesCreateParams = OctokitRest.RestEndpointMethodTypes['issues']['create']['parameters']; - export type IssuesCreateResponseData = OctokitRest.RestEndpointMethodTypes['issues']['create']['response']['data']; - export type IssuesListCommentsResponseData = OctokitRest.RestEndpointMethodTypes['issues']['listComments']['response']['data']; - export type IssuesListEventsForTimelineResponseData = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/timeline']['response']['data']; - export type IssuesListEventsForTimelineResponseItemActor = IssuesListEventsForTimelineResponseData[0]['actor']; - export type PullsCreateParams = OctokitRest.RestEndpointMethodTypes['pulls']['create']['parameters']; - export type PullsCreateReviewResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews']['response']['data']; - export type PullsCreateReviewCommentResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/comments']['response']['data']; - export type PullsGetResponseData = OctokitRest.RestEndpointMethodTypes['pulls']['get']['response']['data']; - export type PullsGetResponseUser = Exclude; - export type PullsListCommitsResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']; - export type PullsListRequestedReviewersResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']; - export type PullsListResponseItem = OctokitRest.RestEndpointMethodTypes['pulls']['list']['response']['data'][0]; - export type PullsListResponseItemHead = PullsListResponseItem['head']; - export type PullsListResponseItemBase = PullsListResponseItem['base']; - export type PullsListResponseItemHeadRepo = PullsListResponseItemHead['repo']; - export type PullsListResponseItemBaseRepo = PullsListResponseItemBase['repo']; - export type PullsListResponseItemUser = Exclude; - export type PullsListResponseItemAssignee = PullsListResponseItem['assignee']; - export type PullsListResponseItemAssigneesItem = (Exclude)[0]; - export type PullsListResponseItemRequestedReviewersItem = (Exclude)[0]; - export type PullsListResponseItemBaseUser = PullsListResponseItemBase['user']; - export type PullsListResponseItemBaseRepoOwner = PullsListResponseItemBase['repo']['owner']; - export type PullsListResponseItemHeadUser = PullsListResponseItemHead['user']; - export type PullsListResponseItemHeadRepoOwner = PullsListResponseItemHead['repo']['owner']; - export type PullsListReviewRequestsResponseTeamsItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']['teams'][0]; - export type PullsListResponseItemHeadRepoTemplateRepository = PullsListResponseItem['head']['repo']['template_repository']; - export type PullsListCommitsResponseItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data'][0]; - export type ReposCompareCommitsResponseData = OctokitRest.RestEndpointMethodTypes['repos']['compareCommits']['response']['data']; - export type ReposGetCombinedStatusForRefResponseStatusesItem = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}/status']['response']['data']['statuses'][0]; - export type ReposGetCommitResponseData = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']; - export type ReposGetCommitResponseFiles = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; - export type ReposGetResponseData = Endpoints['GET /repos/{owner}/{repo}']['response']['data']; - export type ReposGetResponseCodeOfConduct = ReposGetResponseData['code_of_conduct']; - export type ReposGetResponseOrganization = ReposGetResponseData['organization']; - export type ReposListBranchesResponseData = Endpoints['GET /repos/{owner}/{repo}/branches']['response']['data']; - export type SearchReposResponseItem = Endpoints['GET /search/repositories']['response']['data']['items'][0]; - export type CompareCommits = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data']; - export type Commit = CompareCommits['commits'][0]; - export type CommitFile = CompareCommits['files'][0]; -} - -export type Schema = { [key: string]: any, definitions: any[]; }; -export function mergeQuerySchemaWithShared(sharedSchema: Schema, schema: Schema) { - const sharedSchemaDefinitions = sharedSchema.definitions; - const schemaDefinitions = schema.definitions; - const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions); - return { - ...schema, - ...sharedSchema, - definitions: mergedDefinitions - }; -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as OctokitRest from '@octokit/rest'; + +import { Endpoints } from '@octokit/types'; + +export namespace OctokitCommon { + export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; + export type IssuesCreateParams = OctokitRest.RestEndpointMethodTypes['issues']['create']['parameters']; + export type IssuesCreateResponseData = OctokitRest.RestEndpointMethodTypes['issues']['create']['response']['data']; + export type IssuesListCommentsResponseData = OctokitRest.RestEndpointMethodTypes['issues']['listComments']['response']['data']; + export type IssuesListEventsForTimelineResponseData = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/timeline']['response']['data']; + export type IssuesListEventsForTimelineResponseItemActor = IssuesListEventsForTimelineResponseData[0]['actor']; + export type PullsCreateParams = OctokitRest.RestEndpointMethodTypes['pulls']['create']['parameters']; + export type PullsCreateReviewResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews']['response']['data']; + export type PullsCreateReviewCommentResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/comments']['response']['data']; + export type PullsGetResponseData = OctokitRest.RestEndpointMethodTypes['pulls']['get']['response']['data']; + export type PullsGetResponseUser = Exclude; + export type PullsListCommitsResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']; + export type PullsListRequestedReviewersResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']; + export type PullsListResponseItem = OctokitRest.RestEndpointMethodTypes['pulls']['list']['response']['data'][0]; + export type PullsListResponseItemHead = PullsListResponseItem['head']; + export type PullsListResponseItemBase = PullsListResponseItem['base']; + export type PullsListResponseItemHeadRepo = PullsListResponseItemHead['repo']; + export type PullsListResponseItemBaseRepo = PullsListResponseItemBase['repo']; + export type PullsListResponseItemUser = Exclude; + export type PullsListResponseItemAssignee = PullsListResponseItem['assignee']; + export type PullsListResponseItemAssigneesItem = (Exclude)[0]; + export type PullsListResponseItemRequestedReviewersItem = (Exclude)[0]; + export type PullsListResponseItemBaseUser = PullsListResponseItemBase['user']; + export type PullsListResponseItemBaseRepoOwner = PullsListResponseItemBase['repo']['owner']; + export type PullsListResponseItemHeadUser = PullsListResponseItemHead['user']; + export type PullsListResponseItemHeadRepoOwner = PullsListResponseItemHead['repo']['owner']; + export type PullsListReviewRequestsResponseTeamsItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']['teams'][0]; + export type PullsListResponseItemHeadRepoTemplateRepository = PullsListResponseItem['head']['repo']['template_repository']; + export type PullsListCommitsResponseItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data'][0]; + export type ReposCompareCommitsResponseData = OctokitRest.RestEndpointMethodTypes['repos']['compareCommits']['response']['data']; + export type ReposGetCombinedStatusForRefResponseStatusesItem = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}/status']['response']['data']['statuses'][0]; + export type ReposGetCommitResponseData = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']; + export type ReposGetCommitResponseFiles = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; + export type ReposGetResponseData = Endpoints['GET /repos/{owner}/{repo}']['response']['data']; + export type ReposGetResponseCodeOfConduct = ReposGetResponseData['code_of_conduct']; + export type ReposGetResponseOrganization = ReposGetResponseData['organization']; + export type ReposListBranchesResponseData = Endpoints['GET /repos/{owner}/{repo}/branches']['response']['data']; + export type SearchReposResponseItem = Endpoints['GET /search/repositories']['response']['data']['items'][0]; + export type CompareCommits = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data']; + export type Commit = CompareCommits['commits'][0]; + export type CommitFile = CompareCommits['files'][0]; +} + +export type Schema = { [key: string]: any, definitions: any[]; }; +export function mergeQuerySchemaWithShared(sharedSchema: Schema, schema: Schema) { + const sharedSchemaDefinitions = sharedSchema.definitions; + const schemaDefinitions = schema.definitions; + const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions); + return { + ...schema, + ...sharedSchema, + definitions: mergedDefinitions + }; +} diff --git a/src/github/createPRLinkProvider.ts b/src/github/createPRLinkProvider.ts index 9c689c9e77..802793f062 100644 --- a/src/github/createPRLinkProvider.ts +++ b/src/github/createPRLinkProvider.ts @@ -1,119 +1,120 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { PR_SETTINGS_NAMESPACE, TERMINAL_LINK_HANDLER } from '../common/settingKeys'; -import { ReviewManager } from '../view/reviewManager'; -import { FolderRepositoryManager } from './folderRepositoryManager'; - -interface GitHubCreateTerminalLink extends vscode.TerminalLink { - url: string; -} - -export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkProvider { - constructor( - private readonly reviewManager: ReviewManager, - private readonly folderRepositoryManager: FolderRepositoryManager, - ) { } - - private static getSettingsValue() { - return vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get<'vscode' | 'github' | undefined>(TERMINAL_LINK_HANDLER); - } - - static registerProvider(disposables: vscode.Disposable[], reviewManager: ReviewManager, folderManager: FolderRepositoryManager) { - disposables.push( - vscode.window.registerTerminalLinkProvider( - new GitHubCreatePullRequestLinkProvider(reviewManager, folderManager), - ) - ); - } - - provideTerminalLinks( - context: vscode.TerminalLinkContext, - _token: vscode.CancellationToken, - ): vscode.ProviderResult { - const startIndex = context.line.indexOf('https://github.com'); - if (startIndex === -1) { - return []; - } - - /** - * When a branch is published, a line like the following is written to the terminal: - * remote: https://github.com/RMacfarlane/pullrequest-demo/pull/new/rmacfarlane/testbranch3 - */ - const url = context.line.substring(startIndex); - const regex = new RegExp(/https:\/\/github\.com\/(.*)\/(.*)\/pull\/new\/(.*)/); - const result = url.match(regex); - if (result && result.length === 4) { - const owner = result[1]; - const repositoryName = result[2]; - const branchName = result[3]; - - const hasMatchingGitHubRepo = - this.folderRepositoryManager.gitHubRepositories.findIndex( - repo => repo.remote.owner === owner && repo.remote.repositoryName === repositoryName, - ) > -1; - - // The create flow compares against the current branch, so check that the published branch is this branch - if (hasMatchingGitHubRepo && this.reviewManager.repository.state.HEAD?.name === branchName) { - return [ - { - startIndex, - length: context.line.length - startIndex, - tooltip: vscode.l10n.t('Create a Pull Request'), - url, - }, - ]; - } - } - - return []; - } - - private openLink(link: GitHubCreateTerminalLink) { - return vscode.env.openExternal(vscode.Uri.parse(link.url)); - } - - handleTerminalLink(link: GitHubCreateTerminalLink): vscode.ProviderResult { - const defaultHandler = GitHubCreatePullRequestLinkProvider.getSettingsValue(); - - if (defaultHandler === 'github') { - this.openLink(link); - return; - } - - if (defaultHandler === 'vscode') { - this.reviewManager.createPullRequest(); - return; - } - - const yes = 'Yes'; - const neverShow = 'Don\'t Show Again'; - - vscode.window - .showInformationMessage( - 'Do you want to create a pull request using the GitHub Pull Requests and Issues extension?', - yes, - 'No, continue to github.com', - neverShow - ) - .then(notificationResult => { - switch (notificationResult) { - case yes: { - this.reviewManager.createPullRequest(); - break; - } - case neverShow: { - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(TERMINAL_LINK_HANDLER, 'github', vscode.ConfigurationTarget.Global); - this.openLink(link); - break; - } - default: this.openLink(link); - } - }); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { PR_SETTINGS_NAMESPACE, TERMINAL_LINK_HANDLER } from '../common/settingKeys'; +import { ReviewManager } from '../view/reviewManager'; +import { FolderRepositoryManager } from './folderRepositoryManager'; + +interface GitHubCreateTerminalLink extends vscode.TerminalLink { + url: string; +} + +export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkProvider { + constructor( + private readonly reviewManager: ReviewManager, + private readonly folderRepositoryManager: FolderRepositoryManager, + ) { } + + private static getSettingsValue() { + return vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get<'vscode' | 'github' | undefined>(TERMINAL_LINK_HANDLER); + } + + static registerProvider(disposables: vscode.Disposable[], reviewManager: ReviewManager, folderManager: FolderRepositoryManager) { + disposables.push( + vscode.window.registerTerminalLinkProvider( + new GitHubCreatePullRequestLinkProvider(reviewManager, folderManager), + ) + ); + } + + provideTerminalLinks( + context: vscode.TerminalLinkContext, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + const startIndex = context.line.indexOf('https://github.com'); + if (startIndex === -1) { + return []; + } + + /** + * When a branch is published, a line like the following is written to the terminal: + * remote: https://github.com/RMacfarlane/pullrequest-demo/pull/new/rmacfarlane/testbranch3 + */ + const url = context.line.substring(startIndex); + const regex = new RegExp(/https:\/\/github\.com\/(.*)\/(.*)\/pull\/new\/(.*)/); + const result = url.match(regex); + if (result && result.length === 4) { + const owner = result[1]; + const repositoryName = result[2]; + const branchName = result[3]; + + const hasMatchingGitHubRepo = + this.folderRepositoryManager.gitHubRepositories.findIndex( + repo => repo.remote.owner === owner && repo.remote.repositoryName === repositoryName, + ) > -1; + + // The create flow compares against the current branch, so check that the published branch is this branch + if (hasMatchingGitHubRepo && this.reviewManager.repository.state.HEAD?.name === branchName) { + return [ + { + startIndex, + length: context.line.length - startIndex, + tooltip: vscode.l10n.t('Create a Pull Request'), + url, + }, + ]; + } + } + + return []; + } + + private openLink(link: GitHubCreateTerminalLink) { + return vscode.env.openExternal(vscode.Uri.parse(link.url)); + } + + handleTerminalLink(link: GitHubCreateTerminalLink): vscode.ProviderResult { + const defaultHandler = GitHubCreatePullRequestLinkProvider.getSettingsValue(); + + if (defaultHandler === 'github') { + this.openLink(link); + return; + } + + if (defaultHandler === 'vscode') { + this.reviewManager.createPullRequest(); + return; + } + + const yes = 'Yes'; + const neverShow = 'Don\'t Show Again'; + + vscode.window + .showInformationMessage( + 'Do you want to create a pull request using the GitHub Pull Requests and Issues extension?', + yes, + 'No, continue to github.com', + neverShow + ) + .then(notificationResult => { + switch (notificationResult) { + case yes: { + this.reviewManager.createPullRequest(); + break; + } + case neverShow: { + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(TERMINAL_LINK_HANDLER, 'github', vscode.ConfigurationTarget.Global); + this.openLink(link); + break; + } + default: this.openLink(link); + } + }); + } +} diff --git a/src/github/createPRViewProviderNew.ts b/src/github/createPRViewProviderNew.ts index 4775192211..75eb951360 100644 --- a/src/github/createPRViewProviderNew.ts +++ b/src/github/createPRViewProviderNew.ts @@ -1,1081 +1,1082 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, TitleAndDescriptionArgs } from '../../common/views'; -import type { Branch, Ref } from '../api/api'; -import { GitHubServerType } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { GitHubRemote } from '../common/remote'; -import { - ASSIGN_TO, - CREATE_BASE_BRANCH, - DEFAULT_CREATE_OPTION, - PR_SETTINGS_NAMESPACE, - PULL_REQUEST_DESCRIPTION, - PUSH_BRANCH -} from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { PREVIOUS_CREATE_METHOD } from '../extensionState'; -import { CreatePullRequestDataModel } from '../view/createPullRequestDataModel'; -import { - byRemoteName, - DetachedHeadError, - FolderRepositoryManager, - PullRequestDefaults, - titleAndBodyFrom, -} from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; -import { IAccount, ILabel, IMilestone, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; -import { PullRequestGitHelper } from './pullRequestGitHelper'; -import { PullRequestModel } from './pullRequestModel'; -import { getDefaultMergeMethod } from './pullRequestOverview'; -import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; -import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; - -const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword - -export class CreatePullRequestViewProviderNew extends WebviewViewBase implements vscode.WebviewViewProvider, vscode.Disposable { - private static readonly ID = 'CreatePullRequestViewProvider'; - public readonly viewType = 'github:createPullRequestWebview'; - - private _onDone = new vscode.EventEmitter(); - readonly onDone: vscode.Event = this._onDone.event; - - private _onDidChangeBaseRemote = new vscode.EventEmitter(); - readonly onDidChangeBaseRemote: vscode.Event = this._onDidChangeBaseRemote.event; - - private _onDidChangeBaseBranch = new vscode.EventEmitter(); - readonly onDidChangeBaseBranch: vscode.Event = this._onDidChangeBaseBranch.event; - - private _onDidChangeCompareRemote = new vscode.EventEmitter(); - readonly onDidChangeCompareRemote: vscode.Event = this._onDidChangeCompareRemote.event; - - private _onDidChangeCompareBranch = new vscode.EventEmitter(); - readonly onDidChangeCompareBranch: vscode.Event = this._onDidChangeCompareBranch.event; - - private _compareBranch: string; - private _baseBranch: string; - private _baseRemote: RemoteInfo; - - private _firstLoad: boolean = true; - - constructor( - private readonly telemetry: ITelemetry, - private readonly model: CreatePullRequestDataModel, - extensionUri: vscode.Uri, - private readonly _folderRepositoryManager: FolderRepositoryManager, - private readonly _pullRequestDefaults: PullRequestDefaults, - compareBranch: Branch, - ) { - super(extensionUri); - - this._defaultCompareBranch = compareBranch; - } - - public resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ) { - super.resolveWebviewView(webviewView, _context, _token); - webviewView.webview.html = this._getHtmlForWebview(); - - if (this._firstLoad) { - this._firstLoad = false; - // Reset any stored state. - return this.initializeParams(true); - } else { - return this.initializeParams(); - } - } - - private _defaultCompareBranch: Branch; - get defaultCompareBranch() { - return this._defaultCompareBranch; - } - - set defaultCompareBranch(compareBranch: Branch | undefined) { - const branchChanged = compareBranch && (compareBranch.name !== this._defaultCompareBranch.name); - const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== this._defaultCompareBranch.upstream?.remote); - const commitChanged = compareBranch && (compareBranch.commit !== this._defaultCompareBranch.commit); - if (branchChanged || branchRemoteChanged || commitChanged) { - this._defaultCompareBranch = compareBranch!; - this.changeBranch(compareBranch!.name!, false).then(titleAndDescription => { - const params: Partial = { - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description, - compareBranch: compareBranch?.name, - defaultCompareBranch: compareBranch?.name - }; - if (!branchRemoteChanged) { - return this._postMessage({ - command: 'pr.initialize', - params, - }); - } - }); - - if (branchChanged) { - this._onDidChangeCompareBranch.fire(this._defaultCompareBranch.name!); - } - } - } - - public show(compareBranch?: Branch): void { - if (compareBranch) { - this.defaultCompareBranch = compareBranch; - } - - super.show(); - } - - public static withProgress(task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Thenable) { - return vscode.window.withProgress({ location: { viewId: 'github:createPullRequestWebview' } }, task); - } - - private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - if (compareBranch.upstream) { - const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); - - if (headRepo) { - const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; - const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; - const compareResult = await origin.compareCommits(baseBranch, headBranch); - - return compareResult?.commits; - } - } - - return undefined; - } - - private async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { - let title: string = ''; - let description: string = ''; - const descrptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); - if (descrptionSource === 'none') { - return { title, description }; - } - - // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. - // By default, the base branch we use for comparison is the base branch of origin. Compare this to the - // compare branch if it has a GitHub remote. - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - let useBranchName = this._pullRequestDefaults.base === compareBranch.name; - Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProviderNew.ID); - try { - const name = compareBranch.name; - const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ - this.getTotalGitHubCommits(compareBranch, baseBranch), - name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, - descrptionSource === 'template' ? await this.getPullRequestTemplate() : undefined - ]); - const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); - - Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProviderNew.ID); - if (totalNonMergeCommits === undefined) { - // There is no upstream branch. Use the last commit as the title and description. - useBranchName = false; - } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { - const defaultBranch = await origin.getDefaultBranch(); - useBranchName = defaultBranch !== compareBranch.name; - } - - // Set title - if (useBranchName && name) { - title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; - } else if (name && lastCommit) { - title = lastCommit.title; - } - - // Set description - if (pullRequestTemplate && lastCommit?.body) { - description = `${lastCommit.body}\n\n${pullRequestTemplate}`; - } else if (pullRequestTemplate) { - description = pullRequestTemplate; - } else if (lastCommit?.body && (this._pullRequestDefaults.base !== compareBranch.name)) { - description = lastCommit.body; - } - - // If the description is empty, check to see if the title of the PR contains something that looks like an issue - if (!description) { - const issueExpMatch = title.match(ISSUE_EXPRESSION); - const match = parseIssueExpressionOutput(issueExpMatch); - if (match?.issueNumber && !match.name && !match.owner) { - description = `#${match.issueNumber}`; - const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); - - const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); - if (keyWordMatch) { - description = `${keyWordMatch[0]} ${description}`; - } - } - } - } catch (e) { - // Ignore and fall back to commit message - Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProviderNew.ID); - } - return { title, description }; - } - - private async getPullRequestTemplate(): Promise { - Logger.debug(`Pull request template - enter`, CreatePullRequestViewProviderNew.ID); - const templateUris = await this._folderRepositoryManager.getPullRequestTemplatesWithCache(); - let template: string | undefined; - if (templateUris[0]) { - try { - const templateContent = await vscode.workspace.fs.readFile(templateUris[0]); - template = new TextDecoder('utf-8').decode(templateContent); - } catch (e) { - Logger.warn(`Reading pull request template failed: ${e}`); - return undefined; - } - } - Logger.debug(`Pull request template - done`, CreatePullRequestViewProviderNew.ID); - return template; - } - - private async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { - const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); - return repo.getRepoAccessAndMergeMethods(refetch); - } - - private initializeWhenVisibleDisposable: vscode.Disposable | undefined; - public async initializeParams(reset: boolean = false): Promise { - if (this._view?.visible === false && this.initializeWhenVisibleDisposable === undefined) { - this.initializeWhenVisibleDisposable = this._view?.onDidChangeVisibility(() => { - this.initializeWhenVisibleDisposable?.dispose(); - this.initializeWhenVisibleDisposable = undefined; - void this.initializeParams(); - }); - return; - } - - if (reset) { - // First clear all state ASAP - this._postMessage({ command: 'reset' }); - } - await this.initializeParamsPromise(); - } - - private _alreadyInitializing: Promise | undefined; - private async initializeParamsPromise(): Promise { - if (!this._alreadyInitializing) { - this._alreadyInitializing = this.doInitializeParams(); - this._alreadyInitializing.then(() => { - this._alreadyInitializing = undefined; - }); - } - return this._alreadyInitializing; - } - - private async doInitializeParams(): Promise { - if (!this.defaultCompareBranch) { - throw new DetachedHeadError(this._folderRepositoryManager.repository); - } - - const defaultCompareBranch = this.defaultCompareBranch.name ?? ''; - const [detectedBaseMetadata, remotes, defaultOrigin] = await Promise.all([ - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch'>(CREATE_BASE_BRANCH) === 'createdFromBranch' ? PullRequestGitHelper.getMatchingBaseBranchMetadataForBranch(this._folderRepositoryManager.repository, defaultCompareBranch) : undefined, - this._folderRepositoryManager.getGitHubRemotes(), - this._folderRepositoryManager.getOrigin(this.defaultCompareBranch)]); - - const defaultBaseRemote: RemoteInfo = { - owner: detectedBaseMetadata?.owner ?? this._pullRequestDefaults.owner, - repositoryName: detectedBaseMetadata?.repositoryName ?? this._pullRequestDefaults.repo, - }; - if (defaultBaseRemote.owner !== this._pullRequestDefaults.owner || defaultBaseRemote.repositoryName !== this._pullRequestDefaults.repo) { - this._onDidChangeBaseRemote.fire(defaultBaseRemote); - } - - const defaultCompareRemote: RemoteInfo = { - owner: defaultOrigin.remote.owner, - repositoryName: defaultOrigin.remote.repositoryName, - }; - - const defaultBaseBranch = detectedBaseMetadata?.branch ?? this._pullRequestDefaults.base; - if (defaultBaseBranch !== this._pullRequestDefaults.base) { - this._onDidChangeBaseBranch.fire(defaultBaseBranch); - } - - const [defaultTitleAndDescription, mergeConfiguration, viewerPermission, mergeQueueMethodForBranch] = await Promise.all([ - this.getTitleAndDescription(this.defaultCompareBranch, defaultBaseBranch), - this.getMergeConfiguration(defaultBaseRemote.owner, defaultBaseRemote.repositoryName), - defaultOrigin.getViewerPermission(), - this._folderRepositoryManager.mergeQueueMethodForBranch(defaultBaseBranch, defaultBaseRemote.owner, defaultBaseRemote.repositoryName) - ]); - - const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); - const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); - const repoMergeMethod = getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability); - - // default values are for 'create' - let defaultMergeMethod: MergeMethod = repoMergeMethod; - let isDraftDefault: boolean = false; - let autoMergeDefault: boolean = false; - defaultMergeMethod = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.mergeMethod) ? lastCreateMethod?.mergeMethod : repoMergeMethod; - - if (defaultCreateOption === 'lastUsed') { - defaultMergeMethod = lastCreateMethod?.mergeMethod ?? repoMergeMethod; - isDraftDefault = !!lastCreateMethod?.isDraft; - autoMergeDefault = mergeConfiguration.viewerCanAutoMerge && !!lastCreateMethod?.autoMerge; - } else if (defaultCreateOption === 'createDraft') { - isDraftDefault = true; - } else if (defaultCreateOption === 'createAutoMerge') { - autoMergeDefault = mergeConfiguration.viewerCanAutoMerge; - } - commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); - - const useCopilot: boolean = !!this._folderRepositoryManager.getTitleAndDescriptionProvider('Copilot') && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION) === 'Copilot'); - const defaultTitleAndDescriptionProvider = this._folderRepositoryManager.getTitleAndDescriptionProvider()?.title; - if (defaultTitleAndDescriptionProvider) { - /* __GDPR__ - "pr.defaultTitleAndDescriptionProvider" : { - "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('pr.defaultTitleAndDescriptionProvider', { providerTitle: defaultTitleAndDescriptionProvider }); - } - - const params: CreateParamsNew = { - defaultBaseRemote, - defaultBaseBranch, - defaultCompareRemote, - defaultCompareBranch, - defaultTitle: defaultTitleAndDescription.title, - defaultDescription: defaultTitleAndDescription.description, - defaultMergeMethod, - baseHasMergeQueue: !!mergeQueueMethodForBranch, - remoteCount: remotes.length, - allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, - mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, - autoMergeDefault, - createError: '', - labels: this.labels, - isDraftDefault, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, - generateTitleAndDescriptionTitle: defaultTitleAndDescriptionProvider, - creating: false, - initializeWithGeneratedTitleAndDescription: useCopilot - }; - - Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, CreatePullRequestViewProviderNew.ID); - - this._compareBranch = this.defaultCompareBranch.name ?? ''; - this._baseBranch = defaultBaseBranch; - this._baseRemote = defaultBaseRemote; - - this._postMessage({ - command: 'pr.initialize', - params, - }); - return params; - } - - - private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { - const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); - return remotes.map(remote => { - return { - iconPath: new vscode.ThemeIcon('repo'), - label: `${remote.owner}/${remote.repositoryName}`, - remote: { - owner: remote.owner, - repositoryName: remote.repositoryName, - } - }; - }); - } - - private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> { - let branches: (string | Ref)[]; - if (isBase) { - // For the base, we only want to show branches from GitHub. - branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName); - } else { - // For the compare, we only want to show local branches. - branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name); - } - // TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list. - const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => { - const branchName = typeof branch === 'string' ? branch : branch.name!; - const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = { - iconPath: new vscode.ThemeIcon('git-branch'), - label: branchName, - remote: { - owner: githubRepository.remote.owner, - repositoryName: githubRepository.remote.repositoryName - }, - branch: branchName - }; - return pick; - }); - branchPicks.unshift({ - kind: vscode.QuickPickItemKind.Separator, - label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` - }); - branchPicks.unshift({ - iconPath: new vscode.ThemeIcon('repo'), - label: changeRepoMessage - }); - return branchPicks; - } - - private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { - const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); - - commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); - let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; - if (isBase) { - const baseRemoteChanged = this._baseRemote !== result.remote; - const baseBranchChanged = baseRemoteChanged || this._baseBranch !== result.branch; - this._baseBranch = result.branch; - this._baseRemote = result.remote; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(this._compareBranch); - const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ - this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), - this.getTitleAndDescription(compareBranch, this._baseBranch), - this._folderRepositoryManager.mergeQueueMethodForBranch(this._baseBranch, this._baseRemote.owner, this._baseRemote.repositoryName)]); - let autoMergeDefault = false; - if (mergeConfiguration.viewerCanAutoMerge) { - const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); - const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); - autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); - } - - chooseResult = { - baseRemote: result.remote, - baseBranch: result.branch, - defaultBaseBranch: defaultBranch, - defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), - allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, - baseHasMergeQueue: !!mergeQueueMethodForBranch, - mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, - autoMergeDefault, - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description - }; - if (baseRemoteChanged) { - /* __GDPR__ - "pr.create.changedBaseRemote" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); - this._onDidChangeBaseRemote.fire(this._baseRemote); - } - if (baseBranchChanged) { - /* __GDPR__ - "pr.create.changedBaseBranch" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); - this._onDidChangeBaseBranch.fire(this._baseBranch); - } - } else { - this._compareBranch = result.branch; - chooseResult = { - compareRemote: result.remote, - compareBranch: result.branch, - defaultCompareBranch: defaultBranch - }; - /* __GDPR__ - "pr.create.changedCompare" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); - this._onDidChangeCompareRemote.fire(result.remote); - this._onDidChangeCompareBranch.fire(this._compareBranch); - } - return chooseResult; - } - - private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { - this.cancelGenerateTitleAndDescription(); - const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); - let githubRepository = this._folderRepositoryManager.findRepo( - repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, - ); - - const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); - const remotePlaceholder = vscode.l10n.t('Choose a remote'); - const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); - const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); - - quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder; - quickPick.show(); - quickPick.busy = true; - quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : await this.remotePicks(isBase); - const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; - quickPick.activeItems = activeItem ? [activeItem] : []; - quickPick.busy = false; - const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { - quickPick.onDidAccept(async () => { - if (quickPick.selectedItems.length === 0) { - return; - } - const selectedPick = quickPick.selectedItems[0]; - if (selectedPick.label === chooseDifferentRemote) { - quickPick.busy = true; - quickPick.items = await this.remotePicks(isBase); - quickPick.busy = false; - quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; - } else if ((selectedPick.branch === undefined) && selectedPick.remote) { - const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; - quickPick.busy = true; - githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; - quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); - quickPick.placeholder = branchPlaceholder; - quickPick.busy = false; - } else if (selectedPick.branch && selectedPick.remote) { - const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; - resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); - } - }); - }); - const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); - const result = await Promise.race([remoteAndBranch, hidePromise]); - if (!result || !githubRepository) { - quickPick.hide(); - quickPick.dispose(); - return; - } - - quickPick.busy = true; - const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); - - quickPick.hide(); - quickPick.dispose(); - return this._replyMessage(message, chooseResult); - } - - private async autoAssign(pr: PullRequestModel): Promise { - const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); - if (!configuration) { - return; - } - const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); - if (!resolved) { - return; - } - try { - await pr.addAssignees([resolved]); - } catch (e) { - Logger.error(`Unable to assign pull request to user ${resolved}.`); - } - } - - private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { - if (autoMerge && automergeMethod) { - return pr.enableAutoMerge(automergeMethod); - } - } - - private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { - if (labels.length > 0) { - await pr.setLabels(labels.map(label => label.name)); - } - } - - private async setAssignees(pr: PullRequestModel, assignees: IAccount[]): Promise { - if (assignees.length) { - await pr.addAssignees(assignees.map(assignee => assignee.login)); - } else { - await this.autoAssign(pr); - } - } - - private async setReviewers(pr: PullRequestModel, reviewers: (IAccount | ITeam)[]): Promise { - if (reviewers.length) { - const users: string[] = []; - const teams: string[] = []; - for (const reviewer of reviewers) { - if (isTeam(reviewer)) { - teams.push(reviewer.id); - } else { - users.push(reviewer.id); - } - } - await pr.requestReview(users, teams); - } - } - - private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined): void { - if (milestone) { - pr.updateMilestone(milestone.id); - } - } - - private async getRemote(): Promise { - return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(remote.repositoryName, this._baseRemote.repositoryName) === 0)!; - } - - private milestone: IMilestone | undefined; - public async addMilestone(): Promise { - const remote = await this.getRemote(); - const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; - - return getMilestoneFromQuickPick(this._folderRepositoryManager, repo, this.milestone, (milestone) => { - this.milestone = milestone; - return this._postMessage({ - command: 'set-milestone', - params: { milestone: this.milestone } - }); - }); - } - - private reviewers: (IAccount | ITeam)[] = []; - public async addReviewers(): Promise { - let quickPick: vscode.QuickPick | undefined; - const remote = await this.getRemote(); - try { - const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; - const [metadata, author, teamsCount] = await Promise.all([repo?.getMetadata(), this._folderRepositoryManager.getCurrentUser(), this._folderRepositoryManager.getOrgTeamsCount(repo)]); - quickPick = await reviewersQuickPick(this._folderRepositoryManager, remote.remoteName, !!metadata?.organization, teamsCount, author, this.reviewers.map(reviewer => { return { reviewer, state: 'REQUESTED' }; }), []); - quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; - }); - const hidePromise = asPromise(quickPick.onDidHide); - const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); - quickPick.busy = true; - - if (allReviewers) { - this.reviewers = allReviewers.map(item => item.user); - this._postMessage({ - command: 'set-reviewers', - params: { reviewers: this.reviewers } - }); - } - } catch (e) { - Logger.error(formatError(e)); - vscode.window.showErrorMessage(formatError(e)); - } finally { - quickPick?.hide(); - quickPick?.dispose(); - } - } - - private assignees: IAccount[] = []; - public async addAssignees(): Promise { - const remote = await this.getRemote(); - const assigneesToAdd = await vscode.window.showQuickPick(getAssigneesQuickPickItems(this._folderRepositoryManager, remote.remoteName, this.assignees), - { canPickMany: true, placeHolder: vscode.l10n.t('Add assignees') }); - if (assigneesToAdd) { - const addedAssignees = assigneesToAdd.map(assignee => assignee.user).filter((assignee): assignee is IAccount => !!assignee); - this.assignees = addedAssignees; - this._postMessage({ - command: 'set-assignees', - params: { assignees: this.assignees } - }); - } - } - - private labels: ILabel[] = []; - public async addLabels(): Promise { - let newLabels: ILabel[] = []; - - const labelsToAdd = await vscode.window.showQuickPick( - getLabelOptions(this._folderRepositoryManager, this.labels, this._baseRemote).then(options => { - newLabels = options.newLabels; - return options.labelPicks; - }) as Promise, - { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, - ); - - if (labelsToAdd) { - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - this.labels = addedLabels; - this._postMessage({ - command: 'set-labels', - params: { labels: this.labels } - }); - } - } - - private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { - const { label } = message.args; - if (!label) - return; - - const previousLabelsLength = this.labels.length; - this.labels = this.labels.filter(l => l.name !== label.name); - if (previousLabelsLength === this.labels.length) - return; - - this._postMessage({ - command: 'set-labels', - params: { labels: this.labels } - }); - } - - private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> { - const issues: Promise<{ content: string, reference: string } | undefined>[] = []; - for (const commit of commits) { - const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION)); - if (tryParse) { - const owner = tryParse.owner ?? this._baseRemote.owner; - const name = tryParse.name ?? this._baseRemote.repositoryName; - issues.push(new Promise(resolve => { - this._folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => { - if (issue) { - resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) }); - } else { - resolve(undefined); - } - }); - - })); - } - } - if (issues.length) { - return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[]; - } - return undefined; - } - - private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined; - private async getTitleAndDescriptionFromProvider(token: vscode.CancellationToken, searchTerm?: string) { - return CreatePullRequestViewProviderNew.withProgress(async () => { - try { - let commitMessages: string[]; - let patches: string[]; - if (await this.model.getCompareHasUpstream()) { - [commitMessages, patches] = await Promise.all([ - this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)), - this.model.gitHubFiles().then(rawPatches => rawPatches.map(file => file.patch ?? ''))]); - } else { - [commitMessages, patches] = await Promise.all([ - this.model.gitCommits().then(rawCommits => rawCommits.map(commit => commit.message)), - Promise.all((await this.model.gitFiles()).map(async (file) => { - return this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.getCompareBranch(), file.uri.fsPath); - }))]); - } - - const issues = await this.findIssueContext(commitMessages); - - const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm); - const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues }, token); - - if (provider) { - this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; - /* __GDPR__ - "pr.generatedTitleAndDescription" : { - "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title }); - } - return result; - } catch (e) { - Logger.error(`Error while generating title and description: ${e}`, CreatePullRequestViewProviderNew.ID); - return undefined; - } - }); - } - - private generatingCancellationToken: vscode.CancellationTokenSource | undefined; - private async generateTitleAndDescription(message: IRequestMessage): Promise { - if (this.generatingCancellationToken) { - this.generatingCancellationToken.cancel(); - } - this.generatingCancellationToken = new vscode.CancellationTokenSource(); - - - const result = await Promise.race([this.getTitleAndDescriptionFromProvider(this.generatingCancellationToken.token, message.args.useCopilot ? 'Copilot' : undefined), - new Promise(resolve => this.generatingCancellationToken?.token.onCancellationRequested(() => resolve(true)))]); - - this.generatingCancellationToken = undefined; - - const generated: { title: string | undefined, description: string | undefined } = { title: undefined, description: undefined }; - if (result !== true) { - generated.title = result?.title; - generated.description = result?.description; - } - return this._replyMessage(message, { title: generated?.title, description: generated?.description }); - } - - private async cancelGenerateTitleAndDescription(): Promise { - if (this.generatingCancellationToken) { - this.generatingCancellationToken.cancel(); - } - } - - private async pushUpstream(compareOwner: string, compareRepositoryName: string, compareBranchName: string): Promise<{ compareUpstream: GitHubRemote, repo: GitHubRepository | undefined } | undefined> { - let createdPushRemote: GitHubRemote | undefined; - const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => { - if (!localRemote.pushUrl) { - return false; - } - const testRemote = new GitHubRemote(localRemote.name, localRemote.pushUrl, new Protocol(localRemote.pushUrl), GitHubServerType.GitHubDotCom); - if ((testRemote.owner.toLowerCase() === compareOwner.toLowerCase()) && (testRemote.repositoryName.toLowerCase() === compareRepositoryName.toLowerCase())) { - createdPushRemote = testRemote; - return true; - } - return false; - }); - - if (pushRemote && createdPushRemote) { - Logger.appendLine(`Found push remote ${pushRemote.name} for ${compareOwner}/${compareRepositoryName} and branch ${compareBranchName}`, CreatePullRequestViewProviderNew.ID); - await this._folderRepositoryManager.repository.push(pushRemote.name, compareBranchName, true); - await this._folderRepositoryManager.repository.status(); - return { compareUpstream: createdPushRemote, repo: this._folderRepositoryManager.findRepo(byRemoteName(createdPushRemote.remoteName)) }; - } - } - - public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { - const params: Partial = { - isDraft, - autoMerge, - autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, - creating: true - }; - return this._postMessage({ - command: 'create', - params - }); - } - - private checkGeneratedTitleAndDescription(title: string, description: string) { - if (!this.lastGeneratedTitleAndDescription) { - return; - } - const usedGeneratedTitle: boolean = !!this.lastGeneratedTitleAndDescription.title && ((this.lastGeneratedTitleAndDescription.title === title) || this.lastGeneratedTitleAndDescription.title?.includes(title) || title?.includes(this.lastGeneratedTitleAndDescription.title)); - const usedGeneratedDescription: boolean = !!this.lastGeneratedTitleAndDescription.description && ((this.lastGeneratedTitleAndDescription.description === description) || this.lastGeneratedTitleAndDescription.description?.includes(description) || description?.includes(this.lastGeneratedTitleAndDescription.description)); - /* __GDPR__ - "pr.usedGeneratedTitleAndDescription" : { - "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "usedGeneratedTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "usedGeneratedDescription" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('pr.usedGeneratedTitleAndDescription', { providerTitle: this.lastGeneratedTitleAndDescription.providerTitle, usedGeneratedTitle: usedGeneratedTitle.toString(), usedGeneratedDescription: usedGeneratedDescription.toString() }); - } - - private async create(message: IRequestMessage): Promise { - Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProviderNew.ID); - - // Save create method - const createMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } = { autoMerge: message.args.autoMerge, mergeMethod: message.args.autoMergeMethod, isDraft: message.args.draft }; - this._folderRepositoryManager.context.workspaceState.update(PREVIOUS_CREATE_METHOD, createMethod); - - const postCreate = (createdPR: PullRequestModel) => { - return Promise.all([ - this.setLabels(createdPR, message.args.labels), - this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), - this.setAssignees(createdPR, message.args.assignees), - this.setReviewers(createdPR, message.args.reviewers), - this.setMilestone(createdPR, message.args.milestone)]); - }; - - CreatePullRequestViewProviderNew.withProgress(() => { - return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { - let totalIncrement = 0; - progress.report({ message: vscode.l10n.t('Checking for upstream branch'), increment: totalIncrement }); - let createdPR: PullRequestModel | undefined = undefined; - try { - const compareOwner = message.args.compareOwner; - const compareRepositoryName = message.args.compareRepo; - const compareBranchName = message.args.compareBranch; - const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); - let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { - return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); - }) : undefined; - let existingCompareUpstream = headRepo?.remote; - - if (!existingCompareUpstream - || (existingCompareUpstream.owner !== compareOwner) - || (existingCompareUpstream.repositoryName !== compareRepositoryName)) { - - // We assume this happens only when the compare branch is based on the current branch. - const alwaysPublish = vscode.l10n.t('Always Publish Branch'); - const publish = vscode.l10n.t('Publish Branch'); - const pushBranchSetting = - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; - const messageResult = !pushBranchSetting ? await vscode.window.showInformationMessage( - vscode.l10n.t('There is no remote branch on {0}/{1} for \'{2}\'.\n\nDo you want to publish it and then create the pull request?', compareOwner, compareRepositoryName, compareBranchName), - { modal: true }, - publish, - alwaysPublish) - : publish; - if (messageResult === alwaysPublish) { - await vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .update(PUSH_BRANCH, 'always', vscode.ConfigurationTarget.Global); - } - if ((messageResult === alwaysPublish) || (messageResult === publish)) { - progress.report({ message: vscode.l10n.t('Pushing branch'), increment: 10 }); - totalIncrement += 10; - - const pushResult = await this.pushUpstream(compareOwner, compareRepositoryName, compareBranchName); - if (pushResult) { - existingCompareUpstream = pushResult.compareUpstream; - headRepo = pushResult.repo; - } else { - this._throwError(message, vscode.l10n.t('The current repository does not have a push remote for {0}', compareGithubRemoteName)); - } - } - } - if (!existingCompareUpstream) { - this._throwError(message, vscode.l10n.t('No remote branch on {0}/{1} for the merge branch.', compareOwner, compareRepositoryName)); - progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); - return; - } - - if (!headRepo) { - throw new Error(vscode.l10n.t('Unable to find GitHub repository matching \'{0}\'. You can add \'{0}\' to the setting "githubPullRequests.remotes" to ensure \'{0}\' is found.', existingCompareUpstream.remoteName)); - } - - progress.report({ message: vscode.l10n.t('Creating pull request'), increment: 70 - totalIncrement }); - totalIncrement += 70 - totalIncrement; - const head = `${headRepo.remote.owner}:${compareBranchName}`; - this.checkGeneratedTitleAndDescription(message.args.title, message.args.body); - createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); - - // Create was cancelled - if (!createdPR) { - this._throwError(message, vscode.l10n.t('There must be a difference in commits to create a pull request.')); - } else { - await postCreate(createdPR); - } - } catch (e) { - if (!createdPR) { - let errorMessage: string = e.message; - if (errorMessage.startsWith('GraphQL error: ')) { - errorMessage = errorMessage.substring('GraphQL error: '.length); - } - this._throwError(message, errorMessage); - } else { - if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { - // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. - await postCreate(createdPR); - } - // All of these errors occur after the PR is created, so the error is not critical. - vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); - } - } finally { - let completeMessage: string; - if (createdPR) { - this._onDone.fire(createdPR); - completeMessage = vscode.l10n.t('Pull request created'); - } else { - await this._replyMessage(message, {}); - completeMessage = vscode.l10n.t('Unable to create pull request'); - } - progress.report({ message: completeMessage, increment: 100 - totalIncrement }); - } - }); - }); - } - - private async changeBranch(newBranch: string, isBase: boolean): Promise<{ title: string, description: string }> { - let compareBranch: Branch | undefined; - if (isBase) { - this._baseBranch = newBranch; - this._onDidChangeBaseBranch.fire(newBranch); - } else { - try { - compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); - this._compareBranch = newBranch; - this._onDidChangeCompareBranch.fire(compareBranch.name!); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Branch does not exist locally.')); - } - } - - compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this._compareBranch); - return this.getTitleAndDescription(compareBranch, this._baseBranch); - } - - private async cancel(message: IRequestMessage) { - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); - this._onDone.fire(undefined); - // Re-fetch the automerge info so that it's updated for next time. - await this.getMergeConfiguration(message.args.owner, message.args.repo, true); - return this._replyMessage(message, undefined); - } - - protected async _onDidReceiveMessage(message: IRequestMessage) { - const result = await super._onDidReceiveMessage(message); - if (result !== this.MESSAGE_UNHANDLED) { - return; - } - - switch (message.command) { - case 'pr.requestInitialize': - return this.initializeParamsPromise(); - - case 'pr.cancelCreate': - return this.cancel(message); - - case 'pr.create': - return this.create(message); - - case 'pr.changeBaseRemoteAndBranch': - return this.changeRemoteAndBranch(message, true); - - case 'pr.changeCompareRemoteAndBranch': - return this.changeRemoteAndBranch(message, false); - - case 'pr.changeLabels': - return this.addLabels(); - - case 'pr.changeReviewers': - return this.addReviewers(); - - case 'pr.changeAssignees': - return this.addAssignees(); - - case 'pr.changeMilestone': - return this.addMilestone(); - - case 'pr.removeLabel': - return this.removeLabel(message); - - case 'pr.generateTitleAndDescription': - return this.generateTitleAndDescription(message); - - case 'pr.cancelGenerateTitleAndDescription': - return this.cancelGenerateTitleAndDescription(); - - default: - // Log error - vscode.window.showErrorMessage('Unsupported webview message'); - } - } - - dispose() { - super.dispose(); - this._postMessage({ command: 'reset' }); - } - - private _getHtmlForWebview() { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); - - return ` - - - - - - - Create Pull Request - - -
- - -`; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, TitleAndDescriptionArgs } from '../../common/views'; +import type { Branch, Ref } from '../api/api'; +import { GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { GitHubRemote } from '../common/remote'; +import { + ASSIGN_TO, + CREATE_BASE_BRANCH, + DEFAULT_CREATE_OPTION, + PR_SETTINGS_NAMESPACE, + PULL_REQUEST_DESCRIPTION, + PUSH_BRANCH +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; +import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; +import { PREVIOUS_CREATE_METHOD } from '../extensionState'; +import { CreatePullRequestDataModel } from '../view/createPullRequestDataModel'; +import { + byRemoteName, + DetachedHeadError, + FolderRepositoryManager, + PullRequestDefaults, + titleAndBodyFrom, +} from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { IAccount, ILabel, IMilestone, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { PullRequestGitHelper } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; + +const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword + +export class CreatePullRequestViewProviderNew extends WebviewViewBase implements vscode.WebviewViewProvider, vscode.Disposable { + private static readonly ID = 'CreatePullRequestViewProvider'; + public readonly viewType = 'github:createPullRequestWebview'; + + private _onDone = new vscode.EventEmitter(); + readonly onDone: vscode.Event = this._onDone.event; + + private _onDidChangeBaseRemote = new vscode.EventEmitter(); + readonly onDidChangeBaseRemote: vscode.Event = this._onDidChangeBaseRemote.event; + + private _onDidChangeBaseBranch = new vscode.EventEmitter(); + readonly onDidChangeBaseBranch: vscode.Event = this._onDidChangeBaseBranch.event; + + private _onDidChangeCompareRemote = new vscode.EventEmitter(); + readonly onDidChangeCompareRemote: vscode.Event = this._onDidChangeCompareRemote.event; + + private _onDidChangeCompareBranch = new vscode.EventEmitter(); + readonly onDidChangeCompareBranch: vscode.Event = this._onDidChangeCompareBranch.event; + + private _compareBranch: string; + private _baseBranch: string; + private _baseRemote: RemoteInfo; + + private _firstLoad: boolean = true; + + constructor( + private readonly telemetry: ITelemetry, + private readonly model: CreatePullRequestDataModel, + extensionUri: vscode.Uri, + private readonly _folderRepositoryManager: FolderRepositoryManager, + private readonly _pullRequestDefaults: PullRequestDefaults, + compareBranch: Branch, + ) { + super(extensionUri); + + this._defaultCompareBranch = compareBranch; + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + super.resolveWebviewView(webviewView, _context, _token); + webviewView.webview.html = this._getHtmlForWebview(); + + if (this._firstLoad) { + this._firstLoad = false; + // Reset any stored state. + return this.initializeParams(true); + } else { + return this.initializeParams(); + } + } + + private _defaultCompareBranch: Branch; + get defaultCompareBranch() { + return this._defaultCompareBranch; + } + + set defaultCompareBranch(compareBranch: Branch | undefined) { + const branchChanged = compareBranch && (compareBranch.name !== this._defaultCompareBranch.name); + const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== this._defaultCompareBranch.upstream?.remote); + const commitChanged = compareBranch && (compareBranch.commit !== this._defaultCompareBranch.commit); + if (branchChanged || branchRemoteChanged || commitChanged) { + this._defaultCompareBranch = compareBranch!; + this.changeBranch(compareBranch!.name!, false).then(titleAndDescription => { + const params: Partial = { + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description, + compareBranch: compareBranch?.name, + defaultCompareBranch: compareBranch?.name + }; + if (!branchRemoteChanged) { + return this._postMessage({ + command: 'pr.initialize', + params, + }); + } + }); + + if (branchChanged) { + this._onDidChangeCompareBranch.fire(this._defaultCompareBranch.name!); + } + } + } + + public show(compareBranch?: Branch): void { + if (compareBranch) { + this.defaultCompareBranch = compareBranch; + } + + super.show(); + } + + public static withProgress(task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Thenable) { + return vscode.window.withProgress({ location: { viewId: 'github:createPullRequestWebview' } }, task); + } + + private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + if (compareBranch.upstream) { + const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); + + if (headRepo) { + const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; + const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; + const compareResult = await origin.compareCommits(baseBranch, headBranch); + + return compareResult?.commits; + } + } + + return undefined; + } + + private async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { + let title: string = ''; + let description: string = ''; + const descrptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); + if (descrptionSource === 'none') { + return { title, description }; + } + + // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. + // By default, the base branch we use for comparison is the base branch of origin. Compare this to the + // compare branch if it has a GitHub remote. + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + let useBranchName = this._pullRequestDefaults.base === compareBranch.name; + Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProviderNew.ID); + try { + const name = compareBranch.name; + const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ + this.getTotalGitHubCommits(compareBranch, baseBranch), + name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, + descrptionSource === 'template' ? await this.getPullRequestTemplate() : undefined + ]); + const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); + + Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProviderNew.ID); + if (totalNonMergeCommits === undefined) { + // There is no upstream branch. Use the last commit as the title and description. + useBranchName = false; + } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { + const defaultBranch = await origin.getDefaultBranch(); + useBranchName = defaultBranch !== compareBranch.name; + } + + // Set title + if (useBranchName && name) { + title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + } else if (name && lastCommit) { + title = lastCommit.title; + } + + // Set description + if (pullRequestTemplate && lastCommit?.body) { + description = `${lastCommit.body}\n\n${pullRequestTemplate}`; + } else if (pullRequestTemplate) { + description = pullRequestTemplate; + } else if (lastCommit?.body && (this._pullRequestDefaults.base !== compareBranch.name)) { + description = lastCommit.body; + } + + // If the description is empty, check to see if the title of the PR contains something that looks like an issue + if (!description) { + const issueExpMatch = title.match(ISSUE_EXPRESSION); + const match = parseIssueExpressionOutput(issueExpMatch); + if (match?.issueNumber && !match.name && !match.owner) { + description = `#${match.issueNumber}`; + const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); + + const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); + if (keyWordMatch) { + description = `${keyWordMatch[0]} ${description}`; + } + } + } + } catch (e) { + // Ignore and fall back to commit message + Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProviderNew.ID); + } + return { title, description }; + } + + private async getPullRequestTemplate(): Promise { + Logger.debug(`Pull request template - enter`, CreatePullRequestViewProviderNew.ID); + const templateUris = await this._folderRepositoryManager.getPullRequestTemplatesWithCache(); + let template: string | undefined; + if (templateUris[0]) { + try { + const templateContent = await vscode.workspace.fs.readFile(templateUris[0]); + template = new TextDecoder('utf-8').decode(templateContent); + } catch (e) { + Logger.warn(`Reading pull request template failed: ${e}`); + return undefined; + } + } + Logger.debug(`Pull request template - done`, CreatePullRequestViewProviderNew.ID); + return template; + } + + private async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { + const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); + return repo.getRepoAccessAndMergeMethods(refetch); + } + + private initializeWhenVisibleDisposable: vscode.Disposable | undefined; + public async initializeParams(reset: boolean = false): Promise { + if (this._view?.visible === false && this.initializeWhenVisibleDisposable === undefined) { + this.initializeWhenVisibleDisposable = this._view?.onDidChangeVisibility(() => { + this.initializeWhenVisibleDisposable?.dispose(); + this.initializeWhenVisibleDisposable = undefined; + void this.initializeParams(); + }); + return; + } + + if (reset) { + // First clear all state ASAP + this._postMessage({ command: 'reset' }); + } + await this.initializeParamsPromise(); + } + + private _alreadyInitializing: Promise | undefined; + private async initializeParamsPromise(): Promise { + if (!this._alreadyInitializing) { + this._alreadyInitializing = this.doInitializeParams(); + this._alreadyInitializing.then(() => { + this._alreadyInitializing = undefined; + }); + } + return this._alreadyInitializing; + } + + private async doInitializeParams(): Promise { + if (!this.defaultCompareBranch) { + throw new DetachedHeadError(this._folderRepositoryManager.repository); + } + + const defaultCompareBranch = this.defaultCompareBranch.name ?? ''; + const [detectedBaseMetadata, remotes, defaultOrigin] = await Promise.all([ + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch'>(CREATE_BASE_BRANCH) === 'createdFromBranch' ? PullRequestGitHelper.getMatchingBaseBranchMetadataForBranch(this._folderRepositoryManager.repository, defaultCompareBranch) : undefined, + this._folderRepositoryManager.getGitHubRemotes(), + this._folderRepositoryManager.getOrigin(this.defaultCompareBranch)]); + + const defaultBaseRemote: RemoteInfo = { + owner: detectedBaseMetadata?.owner ?? this._pullRequestDefaults.owner, + repositoryName: detectedBaseMetadata?.repositoryName ?? this._pullRequestDefaults.repo, + }; + if (defaultBaseRemote.owner !== this._pullRequestDefaults.owner || defaultBaseRemote.repositoryName !== this._pullRequestDefaults.repo) { + this._onDidChangeBaseRemote.fire(defaultBaseRemote); + } + + const defaultCompareRemote: RemoteInfo = { + owner: defaultOrigin.remote.owner, + repositoryName: defaultOrigin.remote.repositoryName, + }; + + const defaultBaseBranch = detectedBaseMetadata?.branch ?? this._pullRequestDefaults.base; + if (defaultBaseBranch !== this._pullRequestDefaults.base) { + this._onDidChangeBaseBranch.fire(defaultBaseBranch); + } + + const [defaultTitleAndDescription, mergeConfiguration, viewerPermission, mergeQueueMethodForBranch] = await Promise.all([ + this.getTitleAndDescription(this.defaultCompareBranch, defaultBaseBranch), + this.getMergeConfiguration(defaultBaseRemote.owner, defaultBaseRemote.repositoryName), + defaultOrigin.getViewerPermission(), + this._folderRepositoryManager.mergeQueueMethodForBranch(defaultBaseBranch, defaultBaseRemote.owner, defaultBaseRemote.repositoryName) + ]); + + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + const repoMergeMethod = getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability); + + // default values are for 'create' + let defaultMergeMethod: MergeMethod = repoMergeMethod; + let isDraftDefault: boolean = false; + let autoMergeDefault: boolean = false; + defaultMergeMethod = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.mergeMethod) ? lastCreateMethod?.mergeMethod : repoMergeMethod; + + if (defaultCreateOption === 'lastUsed') { + defaultMergeMethod = lastCreateMethod?.mergeMethod ?? repoMergeMethod; + isDraftDefault = !!lastCreateMethod?.isDraft; + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge && !!lastCreateMethod?.autoMerge; + } else if (defaultCreateOption === 'createDraft') { + isDraftDefault = true; + } else if (defaultCreateOption === 'createAutoMerge') { + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge; + } + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + + const useCopilot: boolean = !!this._folderRepositoryManager.getTitleAndDescriptionProvider('Copilot') && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION) === 'Copilot'); + const defaultTitleAndDescriptionProvider = this._folderRepositoryManager.getTitleAndDescriptionProvider()?.title; + if (defaultTitleAndDescriptionProvider) { + /* __GDPR__ + "pr.defaultTitleAndDescriptionProvider" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.defaultTitleAndDescriptionProvider', { providerTitle: defaultTitleAndDescriptionProvider }); + } + + const params: CreateParamsNew = { + defaultBaseRemote, + defaultBaseBranch, + defaultCompareRemote, + defaultCompareBranch, + defaultTitle: defaultTitleAndDescription.title, + defaultDescription: defaultTitleAndDescription.description, + defaultMergeMethod, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + remoteCount: remotes.length, + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + createError: '', + labels: this.labels, + isDraftDefault, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + generateTitleAndDescriptionTitle: defaultTitleAndDescriptionProvider, + creating: false, + initializeWithGeneratedTitleAndDescription: useCopilot + }; + + Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, CreatePullRequestViewProviderNew.ID); + + this._compareBranch = this.defaultCompareBranch.name ?? ''; + this._baseBranch = defaultBaseBranch; + this._baseRemote = defaultBaseRemote; + + this._postMessage({ + command: 'pr.initialize', + params, + }); + return params; + } + + + private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { + const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); + return remotes.map(remote => { + return { + iconPath: new vscode.ThemeIcon('repo'), + label: `${remote.owner}/${remote.repositoryName}`, + remote: { + owner: remote.owner, + repositoryName: remote.repositoryName, + } + }; + }); + } + + private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> { + let branches: (string | Ref)[]; + if (isBase) { + // For the base, we only want to show branches from GitHub. + branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName); + } else { + // For the compare, we only want to show local branches. + branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name); + } + // TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list. + const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => { + const branchName = typeof branch === 'string' ? branch : branch.name!; + const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = { + iconPath: new vscode.ThemeIcon('git-branch'), + label: branchName, + remote: { + owner: githubRepository.remote.owner, + repositoryName: githubRepository.remote.repositoryName + }, + branch: branchName + }; + return pick; + }); + branchPicks.unshift({ + kind: vscode.QuickPickItemKind.Separator, + label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` + }); + branchPicks.unshift({ + iconPath: new vscode.ThemeIcon('repo'), + label: changeRepoMessage + }); + return branchPicks; + } + + private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { + const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); + + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; + if (isBase) { + const baseRemoteChanged = this._baseRemote !== result.remote; + const baseBranchChanged = baseRemoteChanged || this._baseBranch !== result.branch; + this._baseBranch = result.branch; + this._baseRemote = result.remote; + const compareBranch = await this._folderRepositoryManager.repository.getBranch(this._compareBranch); + const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ + this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), + this.getTitleAndDescription(compareBranch, this._baseBranch), + this._folderRepositoryManager.mergeQueueMethodForBranch(this._baseBranch, this._baseRemote.owner, this._baseRemote.repositoryName)]); + let autoMergeDefault = false; + if (mergeConfiguration.viewerCanAutoMerge) { + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); + } + + chooseResult = { + baseRemote: result.remote, + baseBranch: result.branch, + defaultBaseBranch: defaultBranch, + defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description + }; + if (baseRemoteChanged) { + /* __GDPR__ + "pr.create.changedBaseRemote" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); + this._onDidChangeBaseRemote.fire(this._baseRemote); + } + if (baseBranchChanged) { + /* __GDPR__ + "pr.create.changedBaseBranch" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); + this._onDidChangeBaseBranch.fire(this._baseBranch); + } + } else { + this._compareBranch = result.branch; + chooseResult = { + compareRemote: result.remote, + compareBranch: result.branch, + defaultCompareBranch: defaultBranch + }; + /* __GDPR__ + "pr.create.changedCompare" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); + this._onDidChangeCompareRemote.fire(result.remote); + this._onDidChangeCompareBranch.fire(this._compareBranch); + } + return chooseResult; + } + + private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { + this.cancelGenerateTitleAndDescription(); + const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); + let githubRepository = this._folderRepositoryManager.findRepo( + repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, + ); + + const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); + const remotePlaceholder = vscode.l10n.t('Choose a remote'); + const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); + const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); + + quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder; + quickPick.show(); + quickPick.busy = true; + quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : await this.remotePicks(isBase); + const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + quickPick.busy = false; + const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { + quickPick.onDidAccept(async () => { + if (quickPick.selectedItems.length === 0) { + return; + } + const selectedPick = quickPick.selectedItems[0]; + if (selectedPick.label === chooseDifferentRemote) { + quickPick.busy = true; + quickPick.items = await this.remotePicks(isBase); + quickPick.busy = false; + quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; + } else if ((selectedPick.branch === undefined) && selectedPick.remote) { + const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; + quickPick.busy = true; + githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; + quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); + quickPick.placeholder = branchPlaceholder; + quickPick.busy = false; + } else if (selectedPick.branch && selectedPick.remote) { + const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; + resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); + } + }); + }); + const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); + const result = await Promise.race([remoteAndBranch, hidePromise]); + if (!result || !githubRepository) { + quickPick.hide(); + quickPick.dispose(); + return; + } + + quickPick.busy = true; + const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); + + quickPick.hide(); + quickPick.dispose(); + return this._replyMessage(message, chooseResult); + } + + private async autoAssign(pr: PullRequestModel): Promise { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); + if (!configuration) { + return; + } + const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); + if (!resolved) { + return; + } + try { + await pr.addAssignees([resolved]); + } catch (e) { + Logger.error(`Unable to assign pull request to user ${resolved}.`); + } + } + + private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { + if (autoMerge && automergeMethod) { + return pr.enableAutoMerge(automergeMethod); + } + } + + private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { + if (labels.length > 0) { + await pr.setLabels(labels.map(label => label.name)); + } + } + + private async setAssignees(pr: PullRequestModel, assignees: IAccount[]): Promise { + if (assignees.length) { + await pr.addAssignees(assignees.map(assignee => assignee.login)); + } else { + await this.autoAssign(pr); + } + } + + private async setReviewers(pr: PullRequestModel, reviewers: (IAccount | ITeam)[]): Promise { + if (reviewers.length) { + const users: string[] = []; + const teams: string[] = []; + for (const reviewer of reviewers) { + if (isTeam(reviewer)) { + teams.push(reviewer.id); + } else { + users.push(reviewer.id); + } + } + await pr.requestReview(users, teams); + } + } + + private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined): void { + if (milestone) { + pr.updateMilestone(milestone.id); + } + } + + private async getRemote(): Promise { + return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(remote.repositoryName, this._baseRemote.repositoryName) === 0)!; + } + + private milestone: IMilestone | undefined; + public async addMilestone(): Promise { + const remote = await this.getRemote(); + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + + return getMilestoneFromQuickPick(this._folderRepositoryManager, repo, this.milestone, (milestone) => { + this.milestone = milestone; + return this._postMessage({ + command: 'set-milestone', + params: { milestone: this.milestone } + }); + }); + } + + private reviewers: (IAccount | ITeam)[] = []; + public async addReviewers(): Promise { + let quickPick: vscode.QuickPick | undefined; + const remote = await this.getRemote(); + try { + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + const [metadata, author, teamsCount] = await Promise.all([repo?.getMetadata(), this._folderRepositoryManager.getCurrentUser(), this._folderRepositoryManager.getOrgTeamsCount(repo)]); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, remote.remoteName, !!metadata?.organization, teamsCount, author, this.reviewers.map(reviewer => { return { reviewer, state: 'REQUESTED' }; }), []); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + this.reviewers = allReviewers.map(item => item.user); + this._postMessage({ + command: 'set-reviewers', + params: { reviewers: this.reviewers } + }); + } + } catch (e) { + Logger.error(formatError(e)); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); + } + } + + private assignees: IAccount[] = []; + public async addAssignees(): Promise { + const remote = await this.getRemote(); + const assigneesToAdd = await vscode.window.showQuickPick(getAssigneesQuickPickItems(this._folderRepositoryManager, remote.remoteName, this.assignees), + { canPickMany: true, placeHolder: vscode.l10n.t('Add assignees') }); + if (assigneesToAdd) { + const addedAssignees = assigneesToAdd.map(assignee => assignee.user).filter((assignee): assignee is IAccount => !!assignee); + this.assignees = addedAssignees; + this._postMessage({ + command: 'set-assignees', + params: { assignees: this.assignees } + }); + } + } + + private labels: ILabel[] = []; + public async addLabels(): Promise { + let newLabels: ILabel[] = []; + + const labelsToAdd = await vscode.window.showQuickPick( + getLabelOptions(this._folderRepositoryManager, this.labels, this._baseRemote).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + }) as Promise, + { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, + ); + + if (labelsToAdd) { + const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); + this.labels = addedLabels; + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + } + + private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { + const { label } = message.args; + if (!label) + return; + + const previousLabelsLength = this.labels.length; + this.labels = this.labels.filter(l => l.name !== label.name); + if (previousLabelsLength === this.labels.length) + return; + + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + + private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> { + const issues: Promise<{ content: string, reference: string } | undefined>[] = []; + for (const commit of commits) { + const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION)); + if (tryParse) { + const owner = tryParse.owner ?? this._baseRemote.owner; + const name = tryParse.name ?? this._baseRemote.repositoryName; + issues.push(new Promise(resolve => { + this._folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => { + if (issue) { + resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) }); + } else { + resolve(undefined); + } + }); + + })); + } + } + if (issues.length) { + return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[]; + } + return undefined; + } + + private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined; + private async getTitleAndDescriptionFromProvider(token: vscode.CancellationToken, searchTerm?: string) { + return CreatePullRequestViewProviderNew.withProgress(async () => { + try { + let commitMessages: string[]; + let patches: string[]; + if (await this.model.getCompareHasUpstream()) { + [commitMessages, patches] = await Promise.all([ + this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)), + this.model.gitHubFiles().then(rawPatches => rawPatches.map(file => file.patch ?? ''))]); + } else { + [commitMessages, patches] = await Promise.all([ + this.model.gitCommits().then(rawCommits => rawCommits.map(commit => commit.message)), + Promise.all((await this.model.gitFiles()).map(async (file) => { + return this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.getCompareBranch(), file.uri.fsPath); + }))]); + } + + const issues = await this.findIssueContext(commitMessages); + + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm); + const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues }, token); + + if (provider) { + this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; + /* __GDPR__ + "pr.generatedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title }); + } + return result; + } catch (e) { + Logger.error(`Error while generating title and description: ${e}`, CreatePullRequestViewProviderNew.ID); + return undefined; + } + }); + } + + private generatingCancellationToken: vscode.CancellationTokenSource | undefined; + private async generateTitleAndDescription(message: IRequestMessage): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + this.generatingCancellationToken = new vscode.CancellationTokenSource(); + + + const result = await Promise.race([this.getTitleAndDescriptionFromProvider(this.generatingCancellationToken.token, message.args.useCopilot ? 'Copilot' : undefined), + new Promise(resolve => this.generatingCancellationToken?.token.onCancellationRequested(() => resolve(true)))]); + + this.generatingCancellationToken = undefined; + + const generated: { title: string | undefined, description: string | undefined } = { title: undefined, description: undefined }; + if (result !== true) { + generated.title = result?.title; + generated.description = result?.description; + } + return this._replyMessage(message, { title: generated?.title, description: generated?.description }); + } + + private async cancelGenerateTitleAndDescription(): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + } + + private async pushUpstream(compareOwner: string, compareRepositoryName: string, compareBranchName: string): Promise<{ compareUpstream: GitHubRemote, repo: GitHubRepository | undefined } | undefined> { + let createdPushRemote: GitHubRemote | undefined; + const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => { + if (!localRemote.pushUrl) { + return false; + } + const testRemote = new GitHubRemote(localRemote.name, localRemote.pushUrl, new Protocol(localRemote.pushUrl), GitHubServerType.GitHubDotCom); + if ((testRemote.owner.toLowerCase() === compareOwner.toLowerCase()) && (testRemote.repositoryName.toLowerCase() === compareRepositoryName.toLowerCase())) { + createdPushRemote = testRemote; + return true; + } + return false; + }); + + if (pushRemote && createdPushRemote) { + Logger.appendLine(`Found push remote ${pushRemote.name} for ${compareOwner}/${compareRepositoryName} and branch ${compareBranchName}`, CreatePullRequestViewProviderNew.ID); + await this._folderRepositoryManager.repository.push(pushRemote.name, compareBranchName, true); + await this._folderRepositoryManager.repository.status(); + return { compareUpstream: createdPushRemote, repo: this._folderRepositoryManager.findRepo(byRemoteName(createdPushRemote.remoteName)) }; + } + } + + public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { + const params: Partial = { + isDraft, + autoMerge, + autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, + creating: true + }; + return this._postMessage({ + command: 'create', + params + }); + } + + private checkGeneratedTitleAndDescription(title: string, description: string) { + if (!this.lastGeneratedTitleAndDescription) { + return; + } + const usedGeneratedTitle: boolean = !!this.lastGeneratedTitleAndDescription.title && ((this.lastGeneratedTitleAndDescription.title === title) || this.lastGeneratedTitleAndDescription.title?.includes(title) || title?.includes(this.lastGeneratedTitleAndDescription.title)); + const usedGeneratedDescription: boolean = !!this.lastGeneratedTitleAndDescription.description && ((this.lastGeneratedTitleAndDescription.description === description) || this.lastGeneratedTitleAndDescription.description?.includes(description) || description?.includes(this.lastGeneratedTitleAndDescription.description)); + /* __GDPR__ + "pr.usedGeneratedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedDescription" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.usedGeneratedTitleAndDescription', { providerTitle: this.lastGeneratedTitleAndDescription.providerTitle, usedGeneratedTitle: usedGeneratedTitle.toString(), usedGeneratedDescription: usedGeneratedDescription.toString() }); + } + + private async create(message: IRequestMessage): Promise { + Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProviderNew.ID); + + // Save create method + const createMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } = { autoMerge: message.args.autoMerge, mergeMethod: message.args.autoMergeMethod, isDraft: message.args.draft }; + this._folderRepositoryManager.context.workspaceState.update(PREVIOUS_CREATE_METHOD, createMethod); + + const postCreate = (createdPR: PullRequestModel) => { + return Promise.all([ + this.setLabels(createdPR, message.args.labels), + this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), + this.setAssignees(createdPR, message.args.assignees), + this.setReviewers(createdPR, message.args.reviewers), + this.setMilestone(createdPR, message.args.milestone)]); + }; + + CreatePullRequestViewProviderNew.withProgress(() => { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { + let totalIncrement = 0; + progress.report({ message: vscode.l10n.t('Checking for upstream branch'), increment: totalIncrement }); + let createdPR: PullRequestModel | undefined = undefined; + try { + const compareOwner = message.args.compareOwner; + const compareRepositoryName = message.args.compareRepo; + const compareBranchName = message.args.compareBranch; + const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; + const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { + return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); + }) : undefined; + let existingCompareUpstream = headRepo?.remote; + + if (!existingCompareUpstream + || (existingCompareUpstream.owner !== compareOwner) + || (existingCompareUpstream.repositoryName !== compareRepositoryName)) { + + // We assume this happens only when the compare branch is based on the current branch. + const alwaysPublish = vscode.l10n.t('Always Publish Branch'); + const publish = vscode.l10n.t('Publish Branch'); + const pushBranchSetting = + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; + const messageResult = !pushBranchSetting ? await vscode.window.showInformationMessage( + vscode.l10n.t('There is no remote branch on {0}/{1} for \'{2}\'.\n\nDo you want to publish it and then create the pull request?', compareOwner, compareRepositoryName, compareBranchName), + { modal: true }, + publish, + alwaysPublish) + : publish; + if (messageResult === alwaysPublish) { + await vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .update(PUSH_BRANCH, 'always', vscode.ConfigurationTarget.Global); + } + if ((messageResult === alwaysPublish) || (messageResult === publish)) { + progress.report({ message: vscode.l10n.t('Pushing branch'), increment: 10 }); + totalIncrement += 10; + + const pushResult = await this.pushUpstream(compareOwner, compareRepositoryName, compareBranchName); + if (pushResult) { + existingCompareUpstream = pushResult.compareUpstream; + headRepo = pushResult.repo; + } else { + this._throwError(message, vscode.l10n.t('The current repository does not have a push remote for {0}', compareGithubRemoteName)); + } + } + } + if (!existingCompareUpstream) { + this._throwError(message, vscode.l10n.t('No remote branch on {0}/{1} for the merge branch.', compareOwner, compareRepositoryName)); + progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); + return; + } + + if (!headRepo) { + throw new Error(vscode.l10n.t('Unable to find GitHub repository matching \'{0}\'. You can add \'{0}\' to the setting "githubPullRequests.remotes" to ensure \'{0}\' is found.', existingCompareUpstream.remoteName)); + } + + progress.report({ message: vscode.l10n.t('Creating pull request'), increment: 70 - totalIncrement }); + totalIncrement += 70 - totalIncrement; + const head = `${headRepo.remote.owner}:${compareBranchName}`; + this.checkGeneratedTitleAndDescription(message.args.title, message.args.body); + createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); + + // Create was cancelled + if (!createdPR) { + this._throwError(message, vscode.l10n.t('There must be a difference in commits to create a pull request.')); + } else { + await postCreate(createdPR); + } + } catch (e) { + if (!createdPR) { + let errorMessage: string = e.message; + if (errorMessage.startsWith('GraphQL error: ')) { + errorMessage = errorMessage.substring('GraphQL error: '.length); + } + this._throwError(message, errorMessage); + } else { + if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { + // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. + await postCreate(createdPR); + } + // All of these errors occur after the PR is created, so the error is not critical. + vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); + } + } finally { + let completeMessage: string; + if (createdPR) { + this._onDone.fire(createdPR); + completeMessage = vscode.l10n.t('Pull request created'); + } else { + await this._replyMessage(message, {}); + completeMessage = vscode.l10n.t('Unable to create pull request'); + } + progress.report({ message: completeMessage, increment: 100 - totalIncrement }); + } + }); + }); + } + + private async changeBranch(newBranch: string, isBase: boolean): Promise<{ title: string, description: string }> { + let compareBranch: Branch | undefined; + if (isBase) { + this._baseBranch = newBranch; + this._onDidChangeBaseBranch.fire(newBranch); + } else { + try { + compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); + this._compareBranch = newBranch; + this._onDidChangeCompareBranch.fire(compareBranch.name!); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Branch does not exist locally.')); + } + } + + compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this._compareBranch); + return this.getTitleAndDescription(compareBranch, this._baseBranch); + } + + private async cancel(message: IRequestMessage) { + vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); + this._onDone.fire(undefined); + // Re-fetch the automerge info so that it's updated for next time. + await this.getMergeConfiguration(message.args.owner, message.args.repo, true); + return this._replyMessage(message, undefined); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.requestInitialize': + return this.initializeParamsPromise(); + + case 'pr.cancelCreate': + return this.cancel(message); + + case 'pr.create': + return this.create(message); + + case 'pr.changeBaseRemoteAndBranch': + return this.changeRemoteAndBranch(message, true); + + case 'pr.changeCompareRemoteAndBranch': + return this.changeRemoteAndBranch(message, false); + + case 'pr.changeLabels': + return this.addLabels(); + + case 'pr.changeReviewers': + return this.addReviewers(); + + case 'pr.changeAssignees': + return this.addAssignees(); + + case 'pr.changeMilestone': + return this.addMilestone(); + + case 'pr.removeLabel': + return this.removeLabel(message); + + case 'pr.generateTitleAndDescription': + return this.generateTitleAndDescription(message); + + case 'pr.cancelGenerateTitleAndDescription': + return this.cancelGenerateTitleAndDescription(); + + default: + // Log error + vscode.window.showErrorMessage('Unsupported webview message'); + } + } + + dispose() { + super.dispose(); + this._postMessage({ command: 'reset' }); + } + + private _getHtmlForWebview() { + const nonce = getNonce(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); + + return ` + + + + + + + Create Pull Request + + +
+ + +`; + } +} diff --git a/src/github/credentials.ts b/src/github/credentials.ts index 77c403a893..7e49bb88ab 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -1,464 +1,465 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Octokit } from '@octokit/rest'; -import { ApolloClient, InMemoryCache } from 'apollo-boost'; -import { setContext } from 'apollo-link-context'; -import { createHttpLink } from 'apollo-link-http'; -import fetch from 'cross-fetch'; -import * as vscode from 'vscode'; -import { AuthProvider } from '../common/authentication'; -import Logger from '../common/logger'; -import * as PersistentState from '../common/persistentState'; -import { ITelemetry } from '../common/telemetry'; -import { agent } from '../env/node/net'; -import { IAccount } from './interface'; -import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; -import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; - -const TRY_AGAIN = vscode.l10n.t('Try again?'); -const CANCEL = vscode.l10n.t('Cancel'); -const SIGNIN_COMMAND = vscode.l10n.t('Sign In'); -const IGNORE_COMMAND = vscode.l10n.t('Don\'t Show Again'); - -const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); -const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; - -// If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. -const SCOPES_OLDEST = ['read:user', 'user:email', 'repo']; -const SCOPES_OLD = ['read:user', 'user:email', 'repo', 'workflow']; -const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org']; - -const LAST_USED_SCOPES_GITHUB_KEY = 'githubPullRequest.lastUsedScopes'; -const LAST_USED_SCOPES_ENTERPRISE_KEY = 'githubPullRequest.lastUsedScopesEnterprise'; - -export interface GitHub { - octokit: LoggingOctokit; - graphql: LoggingApolloClient; - currentUser?: Promise; -} - -interface AuthResult { - canceled: boolean; -} - -export class CredentialStore implements vscode.Disposable { - private _githubAPI: GitHub | undefined; - private _sessionId: string | undefined; - private _githubEnterpriseAPI: GitHub | undefined; - private _enterpriseSessionId: string | undefined; - private _disposables: vscode.Disposable[]; - private _isInitialized: boolean = false; - private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; - private _scopes: string[] = SCOPES_OLD; - private _scopesEnterprise: string[] = SCOPES_OLD; - - private _onDidGetSession: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidGetSession = this._onDidGetSession.event; - - private _onDidUpgradeSession: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidUpgradeSession = this._onDidUpgradeSession.event; - - constructor(private readonly _telemetry: ITelemetry, private readonly context: vscode.ExtensionContext) { - this.setScopesFromState(); - - this._disposables = []; - this._disposables.push( - vscode.authentication.onDidChangeSessions(async () => { - const promises: Promise[] = []; - if (!this.isAuthenticated(AuthProvider.github)) { - promises.push(this.initialize(AuthProvider.github)); - } - - if (!this.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - promises.push(this.initialize(AuthProvider.githubEnterprise)); - } - - await Promise.all(promises); - if (this.isAnyAuthenticated()) { - this._onDidGetSession.fire(); - } - }), - ); - } - - private allScopesIncluded(actualScopes: string[], requiredScopes: string[]) { - return requiredScopes.every(scope => actualScopes.includes(scope)); - } - - private setScopesFromState() { - this._scopes = this.context.globalState.get(LAST_USED_SCOPES_GITHUB_KEY, SCOPES_OLD); - this._scopesEnterprise = this.context.globalState.get(LAST_USED_SCOPES_ENTERPRISE_KEY, SCOPES_OLD); - } - - private async saveScopesInState() { - await this.context.globalState.update(LAST_USED_SCOPES_GITHUB_KEY, this._scopes); - await this.context.globalState.update(LAST_USED_SCOPES_ENTERPRISE_KEY, this._scopesEnterprise); - } - private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}, scopes: string[] = (!isEnterprise(authProviderId) ? this._scopes : this._scopesEnterprise), requireScopes?: boolean): Promise { - Logger.debug(`Initializing GitHub${getGitHubSuffix(authProviderId)} authentication provider.`, 'Authentication'); - if (isEnterprise(authProviderId)) { - if (!hasEnterpriseUri()) { - Logger.debug(`GitHub Enterprise provider selected without URI.`, 'Authentication'); - return { canceled: false }; - } - } - - if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { - getAuthSessionOptions.createIfNone = false; - } - - let session: vscode.AuthenticationSession | undefined = undefined; - let isNew: boolean = false; - let usedScopes: string[] | undefined = SCOPES_OLD; - const oldScopes = this._scopes; - const oldEnterpriseScopes = this._scopesEnterprise; - const authResult: AuthResult = { canceled: false }; - try { - // Set scopes before getting the session to prevent new session events from using the old scopes. - if (!isEnterprise(authProviderId)) { - this._scopes = scopes; - } else { - this._scopesEnterprise = scopes; - } - const result = await this.getSession(authProviderId, getAuthSessionOptions, scopes, !!requireScopes); - usedScopes = result.scopes; - session = result.session; - isNew = result.isNew; - } catch (e) { - this._scopes = oldScopes; - this._scopesEnterprise = oldEnterpriseScopes; - const userCanceld = (e.message === 'User did not consent to login.'); - if (userCanceld) { - authResult.canceled = true; - } - if (getAuthSessionOptions.forceNewSession && userCanceld) { - // There are cases where a forced login may not be 100% needed, so just continue as usual if - // the user didn't consent to the login prompt. - } else { - throw e; - } - } - - if (session) { - if (!isEnterprise(authProviderId)) { - this._sessionId = session.id; - } else { - this._enterpriseSessionId = session.id; - } - let github: GitHub | undefined; - try { - github = await this.createHub(session.accessToken, authProviderId); - } catch (e) { - if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { - getAuthSessionOptions.forceNewSession = true; - getAuthSessionOptions.silent = false; - return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); - } - } - if (!isEnterprise(authProviderId)) { - this._githubAPI = github; - this._scopes = usedScopes; - } else { - this._githubEnterpriseAPI = github; - this._scopesEnterprise = usedScopes; - } - await this.saveScopesInState(); - - if (!this._isInitialized || isNew) { - this._isInitialized = true; - this._onDidInitialize.fire(); - } - if (isNew) { - /* __GDPR__ - "auth.session" : {} - */ - this._telemetry.sendTelemetryEvent('auth.session'); - } - return authResult; - } else { - Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, 'Authentication'); - return authResult; - } - } - - private async doCreate(options: vscode.AuthenticationGetSessionOptions, additionalScopes: boolean = false): Promise { - const github = await this.initialize(AuthProvider.github, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); - let enterprise: AuthResult | undefined; - if (hasEnterpriseUri()) { - enterprise = await this.initialize(AuthProvider.githubEnterprise, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); - } - return { - canceled: github.canceled || !!(enterprise && enterprise.canceled) - }; - } - - public async create(options: vscode.AuthenticationGetSessionOptions = {}, additionalScopes: boolean = false) { - return this.doCreate(options, additionalScopes); - } - - public async recreate(reason?: string): Promise { - return this.doCreate({ forceNewSession: reason ? { detail: reason } : true }); - } - - public async reset() { - this._githubAPI = undefined; - this._githubEnterpriseAPI = undefined; - return this.create(); - } - - public isAnyAuthenticated() { - return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider.githubEnterprise); - } - - public isAuthenticated(authProviderId: AuthProvider): boolean { - if (!isEnterprise(authProviderId)) { - return !!this._githubAPI; - } - return !!this._githubEnterpriseAPI; - } - - public isAuthenticatedWithAdditionalScopes(authProviderId: AuthProvider): boolean { - if (!isEnterprise(authProviderId)) { - return !!this._githubAPI && this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); - } - return !!this._githubEnterpriseAPI && this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); - } - - public getHub(authProviderId: AuthProvider): GitHub | undefined { - if (!isEnterprise(authProviderId)) { - return this._githubAPI; - } - return this._githubEnterpriseAPI; - } - - public areScopesOld(authProviderId: AuthProvider): boolean { - if (!isEnterprise(authProviderId)) { - return !this.allScopesIncluded(this._scopes, SCOPES_OLD); - } - return !this.allScopesIncluded(this._scopesEnterprise, SCOPES_OLD); - } - - public areScopesExtra(authProviderId: AuthProvider): boolean { - if (!isEnterprise(authProviderId)) { - return this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); - } - return this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); - } - - public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise { - const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId); - await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL, true); - if (!hasScopesAlready) { - this._onDidUpgradeSession.fire(); - } - return this.getHub(authProviderId); - } - - public async getHubOrLogin(authProviderId: AuthProvider): Promise { - if (!isEnterprise(authProviderId)) { - return this._githubAPI ?? (await this.login(authProviderId)); - } - return this._githubEnterpriseAPI ?? (await this.login(authProviderId)); - } - - public async showSignInNotification(authProviderId: AuthProvider): Promise { - if (PersistentState.fetch(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY) === false) { - return; - } - - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('In order to use the Pull Requests functionality, you must sign in to GitHub{0}', getGitHubSuffix(authProviderId)), - SIGNIN_COMMAND, - IGNORE_COMMAND, - ); - - if (result === SIGNIN_COMMAND) { - return await this.login(authProviderId); - } else { - // user cancelled sign in, remember that and don't ask again - PersistentState.store(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY, false); - - /* __GDPR__ - "auth.cancel" : {} - */ - this._telemetry.sendTelemetryEvent('auth.cancel'); - } - } - - public async login(authProviderId: AuthProvider): Promise { - /* __GDPR__ - "auth.start" : {} - */ - this._telemetry.sendTelemetryEvent('auth.start'); - - const errorPrefix = vscode.l10n.t('Error signing in to GitHub{0}', getGitHubSuffix(authProviderId)); - let retry: boolean = true; - let octokit: GitHub | undefined = undefined; - const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; - let isCanceled: boolean = false; - while (retry) { - try { - await this.initialize(authProviderId, sessionOptions); - } catch (e) { - Logger.error(`${errorPrefix}: ${e}`); - if (e instanceof Error && e.stack) { - Logger.error(e.stack); - } - if (e.message === 'Cancelled') { - isCanceled = true; - } - } - octokit = this.getHub(authProviderId); - if (octokit || isCanceled) { - retry = false; - } else { - retry = (await vscode.window.showErrorMessage(errorPrefix, TRY_AGAIN, CANCEL)) === TRY_AGAIN; - if (retry) { - sessionOptions.forceNewSession = true; - sessionOptions.createIfNone = undefined; - } - } - } - - if (octokit) { - /* __GDPR__ - "auth.success" : {} - */ - this._telemetry.sendTelemetryEvent('auth.success'); - } else { - /* __GDPR__ - "auth.fail" : {} - */ - this._telemetry.sendTelemetryEvent('auth.fail'); - } - - return octokit; - } - - public async showSamlMessageAndAuth(organizations: string[]): Promise { - return this.recreate(vscode.l10n.t('GitHub Pull Requests and Issues requires that you provide SAML access to your organization ({0}) when you sign in.', organizations.join(', '))); - } - - public async isCurrentUser(username: string): Promise { - return (await this._githubAPI?.currentUser)?.login === username || (await this._githubEnterpriseAPI?.currentUser)?.login == username; - } - - public getCurrentUser(authProviderId: AuthProvider): Promise { - const github = this.getHub(authProviderId); - const octokit = github?.octokit; - return (octokit && github?.currentUser)!; - } - - private setCurrentUser(github: GitHub): void { - github.currentUser = new Promise(resolve => { - github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => { - resolve(convertRESTUserToAccount(result.data)); - }); - }); - } - - private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions, scopes: string[], requireScopes: boolean): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { - const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await this.findExistingScopes(authProviderId); - if (existingSession?.session) { - return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; - } - - const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); - return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; - } - - private async findExistingScopes(authProviderId: AuthProvider): Promise<{ session: vscode.AuthenticationSession, scopes: string[] } | undefined> { - const scopesInPreferenceOrder = [SCOPES_WITH_ADDITIONAL, SCOPES_OLD, SCOPES_OLDEST]; - for (const scopes of scopesInPreferenceOrder) { - const session = await vscode.authentication.getSession(authProviderId, scopes, { silent: true }); - if (session) { - return { session, scopes }; - } - } - } - - private async createHub(token: string, authProviderId: AuthProvider): Promise { - let baseUrl = 'https://api.github.com'; - let enterpriseServerUri: vscode.Uri | undefined; - if (isEnterprise(authProviderId)) { - enterpriseServerUri = getEnterpriseUri(); - } - - if (enterpriseServerUri && enterpriseServerUri.authority.endsWith('ghe.com')) { - baseUrl = `${enterpriseServerUri.scheme}://api.${enterpriseServerUri.authority}`; - } else if (enterpriseServerUri) { - baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; - } - - let fetchCore: ((url: string, options: { headers?: Record }) => any) | undefined; - if (vscode.env.uiKind === vscode.UIKind.Web) { - fetchCore = (url: string, options: { headers?: Record }) => { - if (options.headers !== undefined) { - const { 'user-agent': userAgent, ...headers } = options.headers; - if (userAgent) { - options.headers = headers; - } - } - return fetch(url, options); - }; - } - - const octokit = new Octokit({ - request: { agent, fetch: fetchCore }, - userAgent: 'GitHub VSCode Pull Requests', - // `shadow-cat-preview` is required for Draft PR API access -- https://developer.github.com/v3/previews/#draft-pull-requests - previews: ['shadow-cat-preview', 'merge-info-preview'], - auth: `${token || ''}`, - baseUrl: baseUrl, - }); - - if (enterpriseServerUri) { - baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; - } - - const graphql = new ApolloClient({ - link: link(baseUrl, token || ''), - cache: new InMemoryCache(), - defaultOptions: { - query: { - fetchPolicy: 'no-cache', - }, - }, - }); - - const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId)); - const github: GitHub = { - octokit: new LoggingOctokit(octokit, rateLogger), - graphql: new LoggingApolloClient(graphql, rateLogger), - }; - this.setCurrentUser(github); - return github; - } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } -} - -const link = (url: string, token: string) => - setContext((_, { headers }) => ({ - headers: { - ...headers, - authorization: token ? `Bearer ${token}` : '', - Accept: 'application/vnd.github.merge-info-preview' - }, - })).concat( - createHttpLink({ - uri: `${url}/graphql`, - // https://github.com/apollographql/apollo-link/issues/513 - fetch: fetch as any, - }), - ); - -function getGitHubSuffix(authProviderId: AuthProvider) { - return !isEnterprise(authProviderId) ? '' : ' Enterprise'; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Octokit } from '@octokit/rest'; +import { ApolloClient, InMemoryCache } from 'apollo-boost'; +import { setContext } from 'apollo-link-context'; +import { createHttpLink } from 'apollo-link-http'; +import fetch from 'cross-fetch'; +import * as vscode from 'vscode'; +import { AuthProvider } from '../common/authentication'; +import Logger from '../common/logger'; +import * as PersistentState from '../common/persistentState'; +import { ITelemetry } from '../common/telemetry'; +import { agent } from '../env/node/net'; +import { IAccount } from './interface'; +import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; +import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; + +const TRY_AGAIN = vscode.l10n.t('Try again?'); +const CANCEL = vscode.l10n.t('Cancel'); +const SIGNIN_COMMAND = vscode.l10n.t('Sign In'); +const IGNORE_COMMAND = vscode.l10n.t('Don\'t Show Again'); + +const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); +const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; + +// If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. +const SCOPES_OLDEST = ['read:user', 'user:email', 'repo']; +const SCOPES_OLD = ['read:user', 'user:email', 'repo', 'workflow']; +const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org']; + +const LAST_USED_SCOPES_GITHUB_KEY = 'githubPullRequest.lastUsedScopes'; +const LAST_USED_SCOPES_ENTERPRISE_KEY = 'githubPullRequest.lastUsedScopesEnterprise'; + +export interface GitHub { + octokit: LoggingOctokit; + graphql: LoggingApolloClient; + currentUser?: Promise; +} + +interface AuthResult { + canceled: boolean; +} + +export class CredentialStore implements vscode.Disposable { + private _githubAPI: GitHub | undefined; + private _sessionId: string | undefined; + private _githubEnterpriseAPI: GitHub | undefined; + private _enterpriseSessionId: string | undefined; + private _disposables: vscode.Disposable[]; + private _isInitialized: boolean = false; + private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; + private _scopes: string[] = SCOPES_OLD; + private _scopesEnterprise: string[] = SCOPES_OLD; + + private _onDidGetSession: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidGetSession = this._onDidGetSession.event; + + private _onDidUpgradeSession: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidUpgradeSession = this._onDidUpgradeSession.event; + + constructor(private readonly _telemetry: ITelemetry, private readonly context: vscode.ExtensionContext) { + this.setScopesFromState(); + + this._disposables = []; + this._disposables.push( + vscode.authentication.onDidChangeSessions(async () => { + const promises: Promise[] = []; + if (!this.isAuthenticated(AuthProvider.github)) { + promises.push(this.initialize(AuthProvider.github)); + } + + if (!this.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + promises.push(this.initialize(AuthProvider.githubEnterprise)); + } + + await Promise.all(promises); + if (this.isAnyAuthenticated()) { + this._onDidGetSession.fire(); + } + }), + ); + } + + private allScopesIncluded(actualScopes: string[], requiredScopes: string[]) { + return requiredScopes.every(scope => actualScopes.includes(scope)); + } + + private setScopesFromState() { + this._scopes = this.context.globalState.get(LAST_USED_SCOPES_GITHUB_KEY, SCOPES_OLD); + this._scopesEnterprise = this.context.globalState.get(LAST_USED_SCOPES_ENTERPRISE_KEY, SCOPES_OLD); + } + + private async saveScopesInState() { + await this.context.globalState.update(LAST_USED_SCOPES_GITHUB_KEY, this._scopes); + await this.context.globalState.update(LAST_USED_SCOPES_ENTERPRISE_KEY, this._scopesEnterprise); + } + private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}, scopes: string[] = (!isEnterprise(authProviderId) ? this._scopes : this._scopesEnterprise), requireScopes?: boolean): Promise { + Logger.debug(`Initializing GitHub${getGitHubSuffix(authProviderId)} authentication provider.`, 'Authentication'); + if (isEnterprise(authProviderId)) { + if (!hasEnterpriseUri()) { + Logger.debug(`GitHub Enterprise provider selected without URI.`, 'Authentication'); + return { canceled: false }; + } + } + + if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { + getAuthSessionOptions.createIfNone = false; + } + + let session: vscode.AuthenticationSession | undefined = undefined; + let isNew: boolean = false; + let usedScopes: string[] | undefined = SCOPES_OLD; + const oldScopes = this._scopes; + const oldEnterpriseScopes = this._scopesEnterprise; + const authResult: AuthResult = { canceled: false }; + try { + // Set scopes before getting the session to prevent new session events from using the old scopes. + if (!isEnterprise(authProviderId)) { + this._scopes = scopes; + } else { + this._scopesEnterprise = scopes; + } + const result = await this.getSession(authProviderId, getAuthSessionOptions, scopes, !!requireScopes); + usedScopes = result.scopes; + session = result.session; + isNew = result.isNew; + } catch (e) { + this._scopes = oldScopes; + this._scopesEnterprise = oldEnterpriseScopes; + const userCanceld = (e.message === 'User did not consent to login.'); + if (userCanceld) { + authResult.canceled = true; + } + if (getAuthSessionOptions.forceNewSession && userCanceld) { + // There are cases where a forced login may not be 100% needed, so just continue as usual if + // the user didn't consent to the login prompt. + } else { + throw e; + } + } + + if (session) { + if (!isEnterprise(authProviderId)) { + this._sessionId = session.id; + } else { + this._enterpriseSessionId = session.id; + } + let github: GitHub | undefined; + try { + github = await this.createHub(session.accessToken, authProviderId); + } catch (e) { + if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { + getAuthSessionOptions.forceNewSession = true; + getAuthSessionOptions.silent = false; + return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); + } + } + if (!isEnterprise(authProviderId)) { + this._githubAPI = github; + this._scopes = usedScopes; + } else { + this._githubEnterpriseAPI = github; + this._scopesEnterprise = usedScopes; + } + await this.saveScopesInState(); + + if (!this._isInitialized || isNew) { + this._isInitialized = true; + this._onDidInitialize.fire(); + } + if (isNew) { + /* __GDPR__ + "auth.session" : {} + */ + this._telemetry.sendTelemetryEvent('auth.session'); + } + return authResult; + } else { + Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, 'Authentication'); + return authResult; + } + } + + private async doCreate(options: vscode.AuthenticationGetSessionOptions, additionalScopes: boolean = false): Promise { + const github = await this.initialize(AuthProvider.github, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + let enterprise: AuthResult | undefined; + if (hasEnterpriseUri()) { + enterprise = await this.initialize(AuthProvider.githubEnterprise, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + } + return { + canceled: github.canceled || !!(enterprise && enterprise.canceled) + }; + } + + public async create(options: vscode.AuthenticationGetSessionOptions = {}, additionalScopes: boolean = false) { + return this.doCreate(options, additionalScopes); + } + + public async recreate(reason?: string): Promise { + return this.doCreate({ forceNewSession: reason ? { detail: reason } : true }); + } + + public async reset() { + this._githubAPI = undefined; + this._githubEnterpriseAPI = undefined; + return this.create(); + } + + public isAnyAuthenticated() { + return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider.githubEnterprise); + } + + public isAuthenticated(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !!this._githubAPI; + } + return !!this._githubEnterpriseAPI; + } + + public isAuthenticatedWithAdditionalScopes(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !!this._githubAPI && this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return !!this._githubEnterpriseAPI && this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + + public getHub(authProviderId: AuthProvider): GitHub | undefined { + if (!isEnterprise(authProviderId)) { + return this._githubAPI; + } + return this._githubEnterpriseAPI; + } + + public areScopesOld(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !this.allScopesIncluded(this._scopes, SCOPES_OLD); + } + return !this.allScopesIncluded(this._scopesEnterprise, SCOPES_OLD); + } + + public areScopesExtra(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + + public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise { + const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId); + await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL, true); + if (!hasScopesAlready) { + this._onDidUpgradeSession.fire(); + } + return this.getHub(authProviderId); + } + + public async getHubOrLogin(authProviderId: AuthProvider): Promise { + if (!isEnterprise(authProviderId)) { + return this._githubAPI ?? (await this.login(authProviderId)); + } + return this._githubEnterpriseAPI ?? (await this.login(authProviderId)); + } + + public async showSignInNotification(authProviderId: AuthProvider): Promise { + if (PersistentState.fetch(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY) === false) { + return; + } + + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('In order to use the Pull Requests functionality, you must sign in to GitHub{0}', getGitHubSuffix(authProviderId)), + SIGNIN_COMMAND, + IGNORE_COMMAND, + ); + + if (result === SIGNIN_COMMAND) { + return await this.login(authProviderId); + } else { + // user cancelled sign in, remember that and don't ask again + PersistentState.store(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY, false); + + /* __GDPR__ + "auth.cancel" : {} + */ + this._telemetry.sendTelemetryEvent('auth.cancel'); + } + } + + public async login(authProviderId: AuthProvider): Promise { + /* __GDPR__ + "auth.start" : {} + */ + this._telemetry.sendTelemetryEvent('auth.start'); + + const errorPrefix = vscode.l10n.t('Error signing in to GitHub{0}', getGitHubSuffix(authProviderId)); + let retry: boolean = true; + let octokit: GitHub | undefined = undefined; + const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; + let isCanceled: boolean = false; + while (retry) { + try { + await this.initialize(authProviderId, sessionOptions); + } catch (e) { + Logger.error(`${errorPrefix}: ${e}`); + if (e instanceof Error && e.stack) { + Logger.error(e.stack); + } + if (e.message === 'Cancelled') { + isCanceled = true; + } + } + octokit = this.getHub(authProviderId); + if (octokit || isCanceled) { + retry = false; + } else { + retry = (await vscode.window.showErrorMessage(errorPrefix, TRY_AGAIN, CANCEL)) === TRY_AGAIN; + if (retry) { + sessionOptions.forceNewSession = true; + sessionOptions.createIfNone = undefined; + } + } + } + + if (octokit) { + /* __GDPR__ + "auth.success" : {} + */ + this._telemetry.sendTelemetryEvent('auth.success'); + } else { + /* __GDPR__ + "auth.fail" : {} + */ + this._telemetry.sendTelemetryEvent('auth.fail'); + } + + return octokit; + } + + public async showSamlMessageAndAuth(organizations: string[]): Promise { + return this.recreate(vscode.l10n.t('GitHub Pull Requests and Issues requires that you provide SAML access to your organization ({0}) when you sign in.', organizations.join(', '))); + } + + public async isCurrentUser(username: string): Promise { + return (await this._githubAPI?.currentUser)?.login === username || (await this._githubEnterpriseAPI?.currentUser)?.login == username; + } + + public getCurrentUser(authProviderId: AuthProvider): Promise { + const github = this.getHub(authProviderId); + const octokit = github?.octokit; + return (octokit && github?.currentUser)!; + } + + private setCurrentUser(github: GitHub): void { + github.currentUser = new Promise(resolve => { + github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => { + resolve(convertRESTUserToAccount(result.data)); + }); + }); + } + + private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions, scopes: string[], requireScopes: boolean): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { + const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await this.findExistingScopes(authProviderId); + if (existingSession?.session) { + return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; + } + + const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); + return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; + } + + private async findExistingScopes(authProviderId: AuthProvider): Promise<{ session: vscode.AuthenticationSession, scopes: string[] } | undefined> { + const scopesInPreferenceOrder = [SCOPES_WITH_ADDITIONAL, SCOPES_OLD, SCOPES_OLDEST]; + for (const scopes of scopesInPreferenceOrder) { + const session = await vscode.authentication.getSession(authProviderId, scopes, { silent: true }); + if (session) { + return { session, scopes }; + } + } + } + + private async createHub(token: string, authProviderId: AuthProvider): Promise { + let baseUrl = 'https://api.github.com'; + let enterpriseServerUri: vscode.Uri | undefined; + if (isEnterprise(authProviderId)) { + enterpriseServerUri = getEnterpriseUri(); + } + + if (enterpriseServerUri && enterpriseServerUri.authority.endsWith('ghe.com')) { + baseUrl = `${enterpriseServerUri.scheme}://api.${enterpriseServerUri.authority}`; + } else if (enterpriseServerUri) { + baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; + } + + let fetchCore: ((url: string, options: { headers?: Record }) => any) | undefined; + if (vscode.env.uiKind === vscode.UIKind.Web) { + fetchCore = (url: string, options: { headers?: Record }) => { + if (options.headers !== undefined) { + const { 'user-agent': userAgent, ...headers } = options.headers; + if (userAgent) { + options.headers = headers; + } + } + return fetch(url, options); + }; + } + + const octokit = new Octokit({ + request: { agent, fetch: fetchCore }, + userAgent: 'GitHub VSCode Pull Requests', + // `shadow-cat-preview` is required for Draft PR API access -- https://developer.github.com/v3/previews/#draft-pull-requests + previews: ['shadow-cat-preview', 'merge-info-preview'], + auth: `${token || ''}`, + baseUrl: baseUrl, + }); + + if (enterpriseServerUri) { + baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; + } + + const graphql = new ApolloClient({ + link: link(baseUrl, token || ''), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + + const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId)); + const github: GitHub = { + octokit: new LoggingOctokit(octokit, rateLogger), + graphql: new LoggingApolloClient(graphql, rateLogger), + }; + this.setCurrentUser(github); + return github; + } + + dispose() { + this._disposables.forEach(disposable => disposable.dispose()); + } +} + +const link = (url: string, token: string) => + setContext((_, { headers }) => ({ + headers: { + ...headers, + authorization: token ? `Bearer ${token}` : '', + Accept: 'application/vnd.github.merge-info-preview' + }, + })).concat( + createHttpLink({ + uri: `${url}/graphql`, + // https://github.com/apollographql/apollo-link/issues/513 + fetch: fetch as any, + }), + ); + +function getGitHubSuffix(authProviderId: AuthProvider) { + return !isEnterprise(authProviderId) ? '' : ' Enterprise'; +} diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 04ff68402e..de87313942 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -1,2499 +1,2500 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { bulkhead } from 'cockatiel'; -import * as vscode from 'vscode'; -import type { Branch, Commit, Repository, UpstreamRef } from '../api/api'; -import { GitApiImpl, GitErrorCodes } from '../api/api1'; -import { GitHubManager } from '../authentication/githubServer'; -import { AuthProvider, GitHubServerType } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import Logger from '../common/logger'; -import { Protocol, ProtocolType } from '../common/protocol'; -import { GitHubRemote, parseRemote, parseRepositoryRemotes, Remote } from '../common/remote'; -import { - ALLOW_FETCH, - AUTO_STASH, - DEFAULT_MERGE_METHOD, - GIT, - PR_SETTINGS_NAMESPACE, - PULL_BEFORE_CHECKOUT, - PULL_BRANCH, - REMOTES, - UPSTREAM_REMOTE, -} from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { EventType } from '../common/timelineEvent'; -import { Schemes } from '../common/uri'; -import { formatError, Predicate } from '../common/utils'; -import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; -import { NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; -import { git } from '../gitProviders/gitCommands'; -import { OctokitCommon } from './common'; -import { CredentialStore } from './credentials'; -import { GitHubRepository, ItemsData, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; -import { PullRequestState, UserResponse } from './graphql'; -import { IAccount, ILabel, IMilestone, IProject, IPullRequestsPagingOptions, Issue, ITeam, MergeMethod, PRType, RepoAccessAndMergeMethods, User } from './interface'; -import { IssueModel } from './issueModel'; -import { MilestoneModel } from './milestoneModel'; -import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; -import { - convertRESTIssueToRawPullRequest, - convertRESTPullRequestToRawPullRequest, - getOverrideBranch, - loginComparator, - parseGraphQLUser, - teamComparator, - variableSubstitution, -} from './utils'; - -interface PageInformation { - pullRequestPage: number; - hasMorePages: boolean | null; -} - -export interface ItemsResponseResult { - items: T[]; - hasMorePages: boolean; - hasUnsearchedRepositories: boolean; -} - -export class NoGitHubReposError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); - } -} - -export class DetachedHeadError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return vscode.l10n.t('{0} has a detached HEAD (create a branch first', this.repository.rootUri.toString()); - } -} - -export class BadUpstreamError extends Error { - constructor(public branchName: string, public upstreamRef: UpstreamRef, public problem: string) { - super(); - } - - get message() { - const { - upstreamRef: { remote, name }, - branchName, - problem, - } = this; - return vscode.l10n.t('The upstream ref {0} for branch {1} {2}.', `${remote}/${name}`, branchName, problem); - } -} - -export const ReposManagerStateContext: string = 'ReposManagerStateContext'; - -export enum ReposManagerState { - Initializing = 'Initializing', - NeedsAuthentication = 'NeedsAuthentication', - RepositoriesLoaded = 'RepositoriesLoaded', -} - -export interface PullRequestDefaults { - owner: string; - repo: string; - base: string; -} - -export const NO_MILESTONE: string = 'No Milestone'; - -enum PagedDataType { - PullRequest, - Milestones, - IssuesWithoutMilestone, - IssueSearch, -} - -const CACHED_TEMPLATE_URI = 'templateUri'; - -export class FolderRepositoryManager implements vscode.Disposable { - static ID = 'FolderRepositoryManager'; - - private _subs: vscode.Disposable[]; - private _activePullRequest?: PullRequestModel; - private _activeIssue?: IssueModel; - private _githubRepositories: GitHubRepository[]; - private _allGitHubRemotes: GitHubRemote[] = []; - private _mentionableUsers?: { [key: string]: IAccount[] }; - private _fetchMentionableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; - private _assignableUsers?: { [key: string]: IAccount[] }; - private _teamReviewers?: { [key: string]: ITeam[] }; - private _fetchAssignableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; - private _fetchTeamReviewersPromise?: Promise<{ [key: string]: ITeam[] }>; - private _gitBlameCache: { [key: string]: string } = {}; - private _githubManager: GitHubManager; - private _repositoryPageInformation: Map = new Map(); - private _addedUpstreamCount: number = 0; - - private _onDidMergePullRequest = new vscode.EventEmitter(); - readonly onDidMergePullRequest = this._onDidMergePullRequest.event; - - private _onDidChangeActivePullRequest = new vscode.EventEmitter<{ new: number | undefined, old: number | undefined }>(); - readonly onDidChangeActivePullRequest: vscode.Event<{ new: number | undefined, old: number | undefined }> = this._onDidChangeActivePullRequest.event; - private _onDidChangeActiveIssue = new vscode.EventEmitter(); - readonly onDidChangeActiveIssue: vscode.Event = this._onDidChangeActiveIssue.event; - - private _onDidLoadRepositories = new vscode.EventEmitter(); - readonly onDidLoadRepositories: vscode.Event = this._onDidLoadRepositories.event; - - private _onDidChangeRepositories = new vscode.EventEmitter(); - readonly onDidChangeRepositories: vscode.Event = this._onDidChangeRepositories.event; - - private _onDidChangeAssignableUsers = new vscode.EventEmitter(); - readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; - - private _onDidChangeGithubRepositories = new vscode.EventEmitter(); - readonly onDidChangeGithubRepositories: vscode.Event = this._onDidChangeGithubRepositories.event; - - private _onDidDispose = new vscode.EventEmitter(); - readonly onDidDispose: vscode.Event = this._onDidDispose.event; - - constructor( - private _id: number, - public context: vscode.ExtensionContext, - private _repository: Repository, - public readonly telemetry: ITelemetry, - private _git: GitApiImpl, - private _credentialStore: CredentialStore, - ) { - this._subs = []; - this._githubRepositories = []; - this._githubManager = new GitHubManager(); - - this._subs.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${REMOTES}`)) { - await this.updateRepositories(); - } - }), - ); - - this._subs.push(_credentialStore.onDidInitialize(() => this.updateRepositories())); - - this.cleanStoredRepoState(); - } - - private cleanStoredRepoState() { - const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; - const reposState = this.context.globalState.get(REPO_KEYS); - if (reposState?.repos) { - let keysChanged = false; - Object.keys(reposState.repos).forEach(repo => { - const repoState = reposState.repos[repo]; - if ((repoState.stateModifiedTime ?? 0) < deleteDate) { - keysChanged = true; - delete reposState.repos[repo]; - } - }); - if (keysChanged) { - this.context.globalState.update(REPO_KEYS, reposState); - } - } - } - - private get id(): string { - return `${FolderRepositoryManager.ID}+${this._id}`; - } - - get gitHubRepositories(): GitHubRepository[] { - return this._githubRepositories; - } - - public async computeAllUnknownRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); - const potentialRemotes = remotes.filter(remote => remote.host); - const serverTypes = await Promise.all( - potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - const unknownRemotes: Remote[] = []; - let i = 0; - for (const potentialRemote of potentialRemotes) { - if (serverTypes[i] === GitHubServerType.None) { - unknownRemotes.push(potentialRemote); - } - i++; - } - return unknownRemotes; - } - - public async computeAllGitHubRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); - const potentialRemotes = remotes.filter(remote => remote.host); - const serverTypes = await Promise.all( - potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - const githubRemotes: GitHubRemote[] = []; - let i = 0; - for (const potentialRemote of potentialRemotes) { - if (serverTypes[i] !== GitHubServerType.None) { - githubRemotes.push(GitHubRemote.remoteAsGitHub(potentialRemote, serverTypes[i])); - } - i++; - } - return githubRemotes; - } - - public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { - const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); - - if (!remotesSetting) { - Logger.error(`Unable to read remotes setting`); - return Promise.resolve([]); - } - - const missingRemotes = remotesSetting.filter(remote => { - return !allGitHubRemotes.some(repo => repo.remoteName === remote); - }); - - if (missingRemotes.length === remotesSetting.length) { - Logger.warn(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); - } else { - Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, this.id); - } - - Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, this.id); - - return remotesSetting - .map(remote => allGitHubRemotes.find(repo => repo.remoteName === remote)) - .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo); - } - - get activeIssue(): IssueModel | undefined { - return this._activeIssue; - } - - set activeIssue(issue: IssueModel | undefined) { - this._activeIssue = issue; - this._onDidChangeActiveIssue.fire(); - } - - get activePullRequest(): PullRequestModel | undefined { - return this._activePullRequest; - } - - set activePullRequest(pullRequest: PullRequestModel | undefined) { - if (pullRequest === this._activePullRequest) { - return; - } - const oldNumber = this._activePullRequest?.number; - if (this._activePullRequest) { - this._activePullRequest.isActive = false; - } - - if (pullRequest) { - pullRequest.isActive = true; - pullRequest.githubRepository.commentsHandler?.unregisterCommentController(pullRequest.number); - } - const newNumber = pullRequest?.number; - - this._activePullRequest = pullRequest; - this._onDidChangeActivePullRequest.fire({ old: oldNumber, new: newNumber }); - } - - get repository(): Repository { - return this._repository; - } - - set repository(repository: Repository) { - this._repository = repository; - } - - get credentialStore(): CredentialStore { - return this._credentialStore; - } - - /** - * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. - * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. - */ - public setFileViewedContext() { - const states = this.activePullRequest?.getViewedFileStates(); - if (states) { - commands.setContext(contexts.VIEWED_FILES, Array.from(states.viewed)); - commands.setContext(contexts.UNVIEWED_FILES, Array.from(states.unviewed)); - } else { - this.clearFileViewedContext(); - } - } - - private clearFileViewedContext() { - commands.setContext(contexts.VIEWED_FILES, []); - commands.setContext(contexts.UNVIEWED_FILES, []); - } - - public async loginAndUpdate() { - if (!this._credentialStore.isAnyAuthenticated()) { - const waitForRepos = new Promise(c => { - const onReposChange = this.onDidChangeRepositories(() => { - onReposChange.dispose(); - c(); - }); - }); - await this._credentialStore.login(AuthProvider.github); - await waitForRepos; - } - } - - private async getActiveRemotes(): Promise { - this._allGitHubRemotes = await this.computeAllGitHubRemotes(); - const activeRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); - - if (activeRemotes.length) { - await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', true); - Logger.appendLine(`Found GitHub remote for folder ${this.repository.rootUri.fsPath}`); - } else { - Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`); - } - - return activeRemotes; - } - - private _updatingRepositories: Promise | undefined; - async updateRepositories(silent: boolean = false): Promise { - if (this._updatingRepositories) { - await this._updatingRepositories; - } - this._updatingRepositories = this.doUpdateRepositories(silent); - return this._updatingRepositories; - } - - private checkForAuthMatch(activeRemotes: GitHubRemote[]): boolean { - // Check that our auth matches the remote. - let dotComCount = 0; - let enterpriseCount = 0; - for (const remote of activeRemotes) { - if (remote.githubServerType === GitHubServerType.GitHubDotCom) { - dotComCount++; - } else if (remote.githubServerType === GitHubServerType.Enterprise) { - enterpriseCount++; - } - } - - let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise); - if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { - // good - } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { - // also good - } else if (isAuthenticated) { - // Not good. We have a mismatch between auth type and server type. - isAuthenticated = false; - } - vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); - return isAuthenticated; - } - - private async doUpdateRepositories(silent: boolean): Promise { - if (this._git.state === 'uninitialized') { - Logger.appendLine('Cannot updates repositories as git is uninitialized'); - - return; - } - - const activeRemotes = await this.getActiveRemotes(); - const isAuthenticated = this.checkForAuthMatch(activeRemotes); - if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { - const areAllNeverGitHub = (await this.computeAllUnknownRemotes()).every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); - if (areAllNeverGitHub) { - this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); - return; - } - } - const repositories: GitHubRepository[] = []; - const resolveRemotePromises: Promise[] = []; - const oldRepositories: GitHubRepository[] = []; - this._githubRepositories.forEach(repo => oldRepositories.push(repo)); - - const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId)); - for (const remote of authenticatedRemotes) { - const repository = await this.createGitHubRepository(remote, this._credentialStore); - resolveRemotePromises.push(repository.resolveRemote()); - repositories.push(repository); - } - - return Promise.all(resolveRemotePromises).then(async (remoteResults: boolean[]) => { - const missingSaml: string[] = []; - for (let i = 0; i < remoteResults.length; i++) { - if (!remoteResults[i]) { - missingSaml.push(repositories[i].remote.owner); - } - } - if (missingSaml.length > 0) { - const result = await this._credentialStore.showSamlMessageAndAuth(missingSaml); - if (result.canceled) { - this.dispose(); - return; - } - } - - this._githubRepositories = repositories; - oldRepositories.filter(old => this._githubRepositories.indexOf(old) < 0).forEach(repo => repo.dispose()); - - const repositoriesChanged = - oldRepositories.length !== this._githubRepositories.length || - !oldRepositories.every(oldRepo => - this._githubRepositories.some(newRepo => newRepo.remote.equals(oldRepo.remote)), - ); - - if (repositoriesChanged) { - this._onDidChangeGithubRepositories.fire(this._githubRepositories); - } - - if (this._githubRepositories.length && repositoriesChanged) { - if (await this.checkIfMissingUpstream()) { - this.updateRepositories(silent); - return; - } - } - - if (this.activePullRequest) { - this.getMentionableUsers(repositoriesChanged); - } - - this.getAssignableUsers(repositoriesChanged); - if (isAuthenticated && activeRemotes.length) { - this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); - } else if (!isAuthenticated) { - this._onDidLoadRepositories.fire(ReposManagerState.NeedsAuthentication); - } - if (!silent) { - this._onDidChangeRepositories.fire(); - } - return; - }); - } - - private async checkIfMissingUpstream(): Promise { - try { - const origin = await this.getOrigin(); - const metadata = await origin.getMetadata(); - const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); - if (metadata.fork && metadata.parent && (configuration.get<'add' | 'never'>(UPSTREAM_REMOTE, 'add') === 'add')) { - const parentUrl = new Protocol(metadata.parent.git_url); - const missingParentRemote = !this._githubRepositories.some( - repo => - repo.remote.owner === parentUrl.owner && - repo.remote.repositoryName === parentUrl.repositoryName, - ); - - if (missingParentRemote) { - const upstreamAvailable = !this.repository.state.remotes.some(remote => remote.name === 'upstream'); - const remoteName = upstreamAvailable ? 'upstream' : metadata.parent.owner?.login; - if (remoteName) { - // check the remotes to see what protocol is being used - const isSSH = this.gitHubRepositories[0].remote.gitProtocol.type === ProtocolType.SSH; - if (isSSH) { - await this.repository.addRemote(remoteName, metadata.parent.ssh_url); - } else { - await this.repository.addRemote(remoteName, metadata.parent.clone_url); - } - this._addedUpstreamCount++; - if (this._addedUpstreamCount > 1) { - // We've already added this remote, which means the user likely removed it. Let the user know they can disable this feature. - const neverOption = vscode.l10n.t('Set to `never`'); - vscode.window.showInformationMessage(vscode.l10n.t('An `upstream` remote has been added for this repository. You can disable this feature by setting `githubPullRequests.upstreamRemote` to `never`.'), neverOption) - .then(choice => { - if (choice === neverOption) { - configuration.update(UPSTREAM_REMOTE, 'never', vscode.ConfigurationTarget.Global); - } - }); - } - return true; - } - } - } - } catch (e) { - Logger.appendLine(`Missing upstream check failed: ${e}`); - // ignore - } - return false; - } - - getAllAssignableUsers(): IAccount[] | undefined { - if (this._assignableUsers) { - const allAssignableUsers: IAccount[] = []; - Object.keys(this._assignableUsers).forEach(k => { - allAssignableUsers.push(...this._assignableUsers![k]); - }); - - return allAssignableUsers; - } - - return undefined; - } - - private async getCachedFromGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects'): Promise<{ [key: string]: T[] } | undefined> { - Logger.appendLine(`Trying to use globalState for ${userKind}.`); - - const usersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); - let usersCacheExists; - try { - usersCacheExists = await vscode.workspace.fs.stat(usersCacheLocation); - } catch (e) { - // file doesn't exit - } - if (!usersCacheExists) { - Logger.appendLine(`GlobalState does not exist for ${userKind}.`); - return undefined; - } - - const cache: { [key: string]: T[] } = {}; - const hasAllRepos = (await Promise.all(this._githubRepositories.map(async (repo) => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; - const repoSpecificFile = vscode.Uri.joinPath(usersCacheLocation, key); - let repoSpecificCache; - let cacheAsJson; - try { - repoSpecificCache = await vscode.workspace.fs.readFile(repoSpecificFile); - cacheAsJson = JSON.parse(repoSpecificCache.toString()); - } catch (e) { - if (e instanceof Error && e.message.includes('Unexpected non-whitespace character after JSON')) { - Logger.error(`Error parsing ${userKind} cache for ${repo.remote.remoteName}.`); - } - // file doesn't exist - } - if (repoSpecificCache && repoSpecificCache.toString()) { - cache[repo.remote.remoteName] = cacheAsJson ?? []; - return true; - } - }))).every(value => value); - if (hasAllRepos) { - Logger.appendLine(`Using globalState ${userKind} for ${Object.keys(cache).length}.`); - return cache; - } - - Logger.appendLine(`No globalState for ${userKind}.`); - return undefined; - } - - private async saveInGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects', cache: { [key: string]: T[] }): Promise { - const cacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); - await Promise.all(this._githubRepositories.map(async (repo) => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; - const repoSpecificFile = vscode.Uri.joinPath(cacheLocation, key); - await vscode.workspace.fs.writeFile(repoSpecificFile, new TextEncoder().encode(JSON.stringify(cache[repo.remote.remoteName]))); - })); - } - - private createFetchMentionableUsersPromise(): Promise<{ [key: string]: IAccount[] }> { - const cache: { [key: string]: IAccount[] } = {}; - return new Promise<{ [key: string]: IAccount[] }>(resolve => { - const promises = this._githubRepositories.map(async githubRepository => { - const data = await githubRepository.getMentionableUsers(); - cache[githubRepository.remote.remoteName] = data; - return; - }); - - Promise.all(promises).then(() => { - this._mentionableUsers = cache; - this._fetchMentionableUsersPromise = undefined; - this.saveInGlobalState('mentionableUsers', cache) - .then(() => resolve(cache)); - }); - }); - } - - async getMentionableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { - if (clearCache) { - delete this._mentionableUsers; - } - - if (this._mentionableUsers) { - Logger.appendLine('Using in-memory cached mentionable users.'); - return this._mentionableUsers; - } - - const globalStateMentionableUsers = await this.getCachedFromGlobalState('mentionableUsers'); - - if (!this._fetchMentionableUsersPromise) { - this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); - return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; - } - - return this._fetchMentionableUsersPromise; - } - - async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { - if (clearCache) { - delete this._assignableUsers; - } - - if (this._assignableUsers) { - Logger.appendLine('Using in-memory cached assignable users.'); - return this._assignableUsers; - } - - const globalStateAssignableUsers = await this.getCachedFromGlobalState('assignableUsers'); - - if (!this._fetchAssignableUsersPromise) { - const cache: { [key: string]: IAccount[] } = {}; - const allAssignableUsers: IAccount[] = []; - this._fetchAssignableUsersPromise = new Promise(resolve => { - const promises = this._githubRepositories.map(async githubRepository => { - const data = await githubRepository.getAssignableUsers(); - cache[githubRepository.remote.remoteName] = data.sort(loginComparator); - allAssignableUsers.push(...data); - return; - }); - - Promise.all(promises).then(() => { - this._assignableUsers = cache; - this._fetchAssignableUsersPromise = undefined; - this.saveInGlobalState('assignableUsers', cache); - resolve(cache); - this._onDidChangeAssignableUsers.fire(allAssignableUsers); - }); - }); - return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; - } - - return this._fetchAssignableUsersPromise; - } - - async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { - if (refreshKind === TeamReviewerRefreshKind.Force) { - delete this._teamReviewers; - } - - if (this._teamReviewers) { - Logger.appendLine('Using in-memory cached team reviewers.'); - return this._teamReviewers; - } - - const globalStateTeamReviewers = (refreshKind === TeamReviewerRefreshKind.Force) ? undefined : await this.getCachedFromGlobalState('teamReviewers'); - if (globalStateTeamReviewers) { - this._teamReviewers = globalStateTeamReviewers; - return globalStateTeamReviewers || {}; - } - - if (!this._fetchTeamReviewersPromise) { - const cache: { [key: string]: ITeam[] } = {}; - return (this._fetchTeamReviewersPromise = new Promise(async (resolve) => { - // Keep track of the org teams we have already gotten so we don't make duplicate calls - const orgTeams: Map = new Map(); - // Go through one github repo at a time so that we don't make overlapping auth calls - for (const githubRepository of this._githubRepositories) { - if (!orgTeams.has(githubRepository.remote.owner)) { - try { - const data = await githubRepository.getOrgTeams(refreshKind); - orgTeams.set(githubRepository.remote.owner, data); - } catch (e) { - break; - } - } - const allTeamsForOrg = orgTeams.get(githubRepository.remote.owner) ?? []; - cache[githubRepository.remote.remoteName] = allTeamsForOrg.filter(team => team.repositoryNames.includes(githubRepository.remote.repositoryName)).sort(teamComparator); - } - - this._teamReviewers = cache; - this._fetchTeamReviewersPromise = undefined; - this.saveInGlobalState('teamReviewers', cache); - resolve(cache); - })); - } - - return this._fetchTeamReviewersPromise; - } - - private createFetchOrgProjectsPromise(): Promise<{ [key: string]: IProject[] }> { - const cache: { [key: string]: IProject[] } = {}; - return new Promise<{ [key: string]: IProject[] }>(async resolve => { - // Keep track of the org teams we have already gotten so we don't make duplicate calls - const orgProjects: Map = new Map(); - // Go through one github repo at a time so that we don't make overlapping auth calls - for (const githubRepository of this._githubRepositories) { - if (!orgProjects.has(githubRepository.remote.owner)) { - try { - const data = await githubRepository.getOrgProjects(); - orgProjects.set(githubRepository.remote.owner, data); - } catch (e) { - break; - } - } - cache[githubRepository.remote.remoteName] = orgProjects.get(githubRepository.remote.owner) ?? []; - } - - await this.saveInGlobalState('orgProjects', cache); - resolve(cache); - }); - } - - async getOrgProjects(clearCache?: boolean): Promise<{ [key: string]: IProject[] }> { - if (clearCache) { - return this.createFetchOrgProjectsPromise(); - } - - const globalStateProjects = await this.getCachedFromGlobalState('orgProjects'); - return globalStateProjects ?? this.createFetchOrgProjectsPromise(); - } - - async getOrgTeamsCount(repository: GitHubRepository): Promise { - if ((await repository.getMetadata()).organization) { - return repository.getOrgTeamsCount(); - } - return 0; - } - - async getPullRequestParticipants(githubRepository: GitHubRepository, pullRequestNumber: number): Promise<{ participants: IAccount[], viewer: IAccount }> { - return { - participants: await githubRepository.getPullRequestParticipants(pullRequestNumber), - viewer: await this.getCurrentUser(githubRepository) - }; - } - - /** - * Returns the remotes that are currently active, which is those that are important by convention (origin, upstream), - * or the remotes configured by the setting githubPullRequests.remotes - */ - async getGitHubRemotes(): Promise { - const githubRepositories = this._githubRepositories; - - if (!githubRepositories || !githubRepositories.length) { - return []; - } - - const remotes = githubRepositories.map(repo => repo.remote).flat(); - - const serverTypes = await Promise.all( - remotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - - const githubRemotes = remotes.map((remote, index) => GitHubRemote.remoteAsGitHub(remote, serverTypes[index])); - if (this.checkForAuthMatch(githubRemotes)) { - return githubRemotes; - } - return []; - } - - /** - * Returns all remotes from the repository. - */ - async getAllGitHubRemotes(): Promise { - return await this.computeAllGitHubRemotes(); - } - - async getLocalPullRequests(): Promise { - const githubRepositories = this._githubRepositories; - - if (!githubRepositories || !githubRepositories.length) { - return []; - } - - const localBranches = (await this.repository.getRefs({ pattern: 'refs/heads/' })) - .filter(r => r.name !== undefined) - .map(r => r.name!); - - // Chunk localBranches into chunks of 100 to avoid hitting the GitHub API rate limit - const chunkedLocalBranches: string[][] = []; - const chunkSize = 100; - for (let i = 0; i < localBranches.length; i += chunkSize) { - const chunk = localBranches.slice(i, i + chunkSize); - chunkedLocalBranches.push(chunk); - } - - const models: (PullRequestModel | undefined)[] = []; - for (const chunk of chunkedLocalBranches) { - models.push(...await Promise.all(chunk.map(async localBranchName => { - const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( - this.repository, - localBranchName, - ); - - if (matchingPRMetadata) { - const { owner, prNumber } = matchingPRMetadata; - const githubRepo = githubRepositories.find( - repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), - ); - - if (githubRepo) { - const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); - - if (pullRequest) { - pullRequest.localBranchName = localBranchName; - return pullRequest; - } - } - } - }))); - } - - return models.filter(value => value !== undefined) as PullRequestModel[]; - } - - async getLabels(issue?: IssueModel, repoInfo?: { owner: string; repo: string }): Promise { - const repo = issue - ? issue.githubRepository - : this._githubRepositories.find( - r => r.remote.owner === repoInfo?.owner && r.remote.repositoryName === repoInfo?.repo, - ); - if (!repo) { - throw new Error(`No matching repository found for getting labels.`); - } - - const { remote, octokit } = await repo.ensure(); - let hasNextPage = false; - let page = 1; - let results: ILabel[] = []; - - do { - const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { - owner: remote.owner, - repo: remote.repositoryName, - per_page: 100, - page, - }); - - results = results.concat( - result.data.map(label => { - return { - name: label.name, - color: label.color, - description: label.description ?? undefined - }; - }), - ); - - results = results.sort((a, b) => a.name.localeCompare(b.name)); - - hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; - page += 1; - } while (hasNextPage); - - return results; - } - - async deleteLocalPullRequest(pullRequest: PullRequestModel, force?: boolean): Promise { - if (!pullRequest.localBranchName) { - return; - } - await this.repository.deleteBranch(pullRequest.localBranchName, force); - - let remoteName: string | undefined = undefined; - try { - remoteName = await this.repository.getConfig(`branch.${pullRequest.localBranchName}.remote`); - } catch (e) { } - - if (!remoteName) { - return; - } - - // If the extension created a remote for the branch, remove it if there are no other branches associated with it - const isPRRemote = await PullRequestGitHelper.isRemoteCreatedForPullRequest(this.repository, remoteName); - if (isPRRemote) { - const configs = await this.repository.getConfigs(); - const hasOtherAssociatedBranches = configs.some( - ({ key, value }) => /^branch.*\.remote$/.test(key) && value === remoteName, - ); - - if (!hasOtherAssociatedBranches) { - await this.repository.removeRemote(remoteName); - } - } - - /* __GDPR__ - "branch.delete" : {} - */ - this.telemetry.sendTelemetryEvent('branch.delete'); - } - - // Keep track of how many pages we've fetched for each query, so when we reload we pull the same ones. - private totalFetchedPages = new Map(); - - /** - * This method works in three different ways: - * 1) Initialize: fetch the first page of the first remote that has pages - * 2) Fetch Next: fetch the next page from this remote, or if it has no more pages, the first page from the next remote that does have pages - * 3) Restore: fetch all the pages you previously have fetched - * - * When `options.fetchNextPage === false`, we are in case 2. - * Otherwise: - * If `this.totalFetchQueries[queryId] === 0`, we are in case 1. - * Otherwise, we're in case 3. - */ - private async fetchPagedData( - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - queryId: string, - pagedDataType: PagedDataType = PagedDataType.PullRequest, - type: PRType = PRType.All, - query?: string, - ): Promise> { - const githubRepositoriesWithGitRemotes = pagedDataType === PagedDataType.PullRequest ? this._githubRepositories.filter(repo => this.repository.state.remotes.find(r => r.name === repo.remote.remoteName)) : this._githubRepositories; - if (!githubRepositoriesWithGitRemotes.length) { - return { - items: [], - hasMorePages: false, - hasUnsearchedRepositories: false, - }; - } - - const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; - const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); - - for (const repository of githubRepositoriesWithGitRemotes) { - const remoteId = repository.remote.url.toString() + queryId; - if (!this._repositoryPageInformation.get(remoteId)) { - this._repositoryPageInformation.set(remoteId, { - pullRequestPage: 0, - hasMorePages: null, - }); - } - } - - let pagesFetched = 0; - const itemData: ItemsData = { hasMorePages: false, items: [] }; - const addPage = (page: PullRequestData | undefined) => { - pagesFetched++; - if (page) { - itemData.items = itemData.items.concat(page.items); - itemData.hasMorePages = page.hasMorePages; - } - }; - - const githubRepositories = this._githubRepositories.filter(repo => { - const info = this._repositoryPageInformation.get(repo.remote.url.toString() + queryId); - // If we are in case 1 or 3, don't filter out repos that are out of pages, as we will be querying from the start. - return info && (options.fetchNextPage === false || info.hasMorePages !== false); - }); - - for (let i = 0; i < githubRepositories.length; i++) { - const githubRepository = githubRepositories[i]; - const remoteId = githubRepository.remote.url.toString() + queryId; - let storedPageInfo = this._repositoryPageInformation.get(remoteId); - if (!storedPageInfo) { - Logger.warn(`No page information for ${remoteId}`); - storedPageInfo = { pullRequestPage: 0, hasMorePages: null }; - this._repositoryPageInformation.set(remoteId, storedPageInfo); - } - const pageInformation = storedPageInfo; - - const fetchPage = async ( - pageNumber: number, - ): Promise<{ items: any[]; hasMorePages: boolean } | undefined> => { - // Resolve variables in the query with each repo - const resolvedQuery = query ? await variableSubstitution(query, undefined, - { base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined; - switch (pagedDataType) { - case PagedDataType.PullRequest: { - if (type === PRType.All) { - return githubRepository.getAllPullRequests(pageNumber); - } else { - return githubRepository.getPullRequestsForCategory(resolvedQuery || '', pageNumber); - } - } - case PagedDataType.Milestones: { - return githubRepository.getIssuesForUserByMilestone(pageInformation.pullRequestPage); - } - case PagedDataType.IssuesWithoutMilestone: { - return githubRepository.getIssuesWithoutMilestone(pageInformation.pullRequestPage); - } - case PagedDataType.IssueSearch: { - return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); - } - } - }; - - if (options.fetchNextPage) { - // Case 2. Fetch a single new page, and increment the global number of pages fetched for this query. - pageInformation.pullRequestPage++; - addPage(await fetchPage(pageInformation.pullRequestPage)); - setTotalFetchedPages(getTotalFetchedPages() + 1); - } else { - // Case 1&3. Fetch all the pages we have fetched in the past, or in case 1, just a single page. - - if (pageInformation.pullRequestPage === 0) { - // Case 1. Pretend we have previously fetched the first page, then hand off to the case 3 machinery to "fetch all pages we have fetched in the past" - pageInformation.pullRequestPage = 1; - } - - const pages = await Promise.all( - Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1)), - ); - pages.forEach(page => addPage(page)); - } - - pageInformation.hasMorePages = itemData.hasMorePages; - - // Break early if - // 1) we've received data AND - // 2) either we're fetching just the next page (case 2) - // OR we're fetching all (cases 1&3), and we've fetched as far as we had previously (or further, in case 1). - if ( - itemData.items.length && - (options.fetchNextPage || - ((options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (pagesFetched >= getTotalFetchedPages()))) - ) { - if (getTotalFetchedPages() === 0) { - // We're in case 1, manually set number of pages we looked through until we found first results. - setTotalFetchedPages(pagesFetched); - } - - return { - items: itemData.items, - hasMorePages: pageInformation.hasMorePages, - hasUnsearchedRepositories: i < githubRepositories.length - 1, - }; - } - } - - return { - items: itemData.items, - hasMorePages: false, - hasUnsearchedRepositories: false, - }; - } - - async getPullRequests( - type: PRType, - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - query?: string, - ): Promise> { - const queryId = type.toString() + (query || ''); - return this.fetchPagedData(options, queryId, PagedDataType.PullRequest, type, query); - } - - async getMilestoneIssues( - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - includeIssuesWithoutMilestone: boolean = false, - ): Promise> { - try { - const milestones: ItemsResponseResult = await this.fetchPagedData( - options, - 'milestoneIssuesKey', - PagedDataType.Milestones, - PRType.All - ); - if (includeIssuesWithoutMilestone) { - const additionalIssues: ItemsResponseResult = await this.fetchPagedData( - options, - 'noMilestoneIssuesKey', - PagedDataType.IssuesWithoutMilestone, - PRType.All - ); - milestones.items.push({ - milestone: { - createdAt: new Date(0).toDateString(), - id: '', - title: NO_MILESTONE, - number: -1 - }, - issues: await Promise.all(additionalIssues.items.map(async (issue) => { - const githubRepository = await this.getRepoForIssue(issue); - return new IssueModel(githubRepository, githubRepository.remote, issue); - })), - }); - } - return milestones; - } catch (e) { - Logger.error(`Error fetching milestone issues: ${e instanceof Error ? e.message : e}`, this.id); - return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; - } - } - - async createMilestone(repository: GitHubRepository, milestoneTitle: string): Promise { - try { - const { data } = await repository.octokit.call(repository.octokit.api.issues.createMilestone, { - owner: repository.remote.owner, - repo: repository.remote.repositoryName, - title: milestoneTitle - }); - return { - title: data.title, - dueOn: data.due_on, - createdAt: data.created_at, - id: data.node_id, - number: data.number - }; - } - catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create a milestone\n{0}', formatError(e))); - return undefined; - } - } - - private async getRepoForIssue(parsedIssue: Issue): Promise { - const remote = new Remote( - parsedIssue.repositoryName!, - parsedIssue.repositoryUrl!, - new Protocol(parsedIssue.repositoryUrl!), - ); - return this.createGitHubRepository(remote, this.credentialStore, true, true); - - } - - /** - * Pull request defaults in the query, like owner and repository variables, will be resolved. - */ - async getIssues( - query?: string, - ): Promise> { - try { - const data = await this.fetchPagedData({ fetchNextPage: false, fetchOnePagePerRepo: false }, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); - const mappedData: ItemsResponseResult = { - items: [], - hasMorePages: data.hasMorePages, - hasUnsearchedRepositories: data.hasUnsearchedRepositories - }; - for (const issue of data.items) { - const githubRepository = await this.getRepoForIssue(issue); - mappedData.items.push(new IssueModel(githubRepository, githubRepository.remote, issue)); - } - return mappedData; - } catch (e) { - Logger.error(`Error fetching issues with query ${query}: ${e instanceof Error ? e.message : e}`, this.id); - return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; - } - } - - async getMaxIssue(): Promise { - const maxIssues = await Promise.all( - this._githubRepositories.map(repository => { - return repository.getMaxIssue(); - }), - ); - let max: number = 0; - for (const issueNumber of maxIssues) { - if (issueNumber !== undefined) { - max = Math.max(max, issueNumber); - } - } - return max; - } - - async getIssueTemplates(): Promise { - const pattern = '{docs,.github}/ISSUE_TEMPLATE/*.md'; - return vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern), null - ); - } - - async getPullRequestTemplatesWithCache(): Promise { - const cacheLocation = `${CACHED_TEMPLATE_URI}+${this.repository.rootUri.toString()}`; - - const findTemplate = this.getPullRequestTemplates().then((templates) => { - //update cache - if (templates.length > 0) { - this.context.workspaceState.update(cacheLocation, templates[0].toString()); - } else { - this.context.workspaceState.update(cacheLocation, null); - } - return templates; - }); - const hasCachedTemplate = this.context.workspaceState.keys().includes(cacheLocation); - const cachedTemplateLocation = this.context.workspaceState.get(cacheLocation); - if (hasCachedTemplate) { - if (cachedTemplateLocation === null) { - return []; - } else if (cachedTemplateLocation) { - return [vscode.Uri.parse(cachedTemplateLocation)]; - } - } - return findTemplate; - } - - private async getPullRequestTemplates(): Promise { - /** - * Places a PR template can be: - * - At the root, the docs folder, or the.github folder, named pull_request_template.md or PULL_REQUEST_TEMPLATE.md - * - At the same folder locations under a PULL_REQUEST_TEMPLATE folder with any name - */ - const pattern1 = '{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; - const templatesPattern1 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern1) - ); - - const pattern2 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; - const templatesPattern2 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern2), null - ); - - const pattern3 = '{pull_request_template,PULL_REQUEST_TEMPLATE}'; - const templatesPattern3 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern3) - ); - - const pattern4 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}'; - const templatesPattern4 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern4), null - ); - - const pattern5 = 'PULL_REQUEST_TEMPLATE/*.md'; - const templatesPattern5 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern5) - ); - - const pattern6 = '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md'; - const templatesPattern6 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern6), null - ); - - const allResults = await Promise.all([templatesPattern1, templatesPattern2, templatesPattern3, templatesPattern4, templatesPattern5, templatesPattern6]); - - const result = [...allResults[0], ...allResults[1], ...allResults[2], ...allResults[3], ...allResults[4], ...allResults[5]]; - return result; - } - - async getPullRequestDefaults(branch?: Branch): Promise { - if (!branch && !this.repository.state.HEAD) { - throw new DetachedHeadError(this.repository); - } - - const origin = await this.getOrigin(branch); - const meta = await origin.getMetadata(); - const remotesSettingDefault = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(REMOTES)?.defaultValue; - const remotesSettingSetValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); - const settingsEqual = (!remotesSettingSetValue || remotesSettingDefault?.every((value, index) => remotesSettingSetValue[index] === value)); - const parent = (meta.fork && meta.parent && settingsEqual) - ? meta.parent - : await (this.findRepo(byRemoteName('upstream')) || origin).getMetadata(); - - return { - owner: parent.owner!.login, - repo: parent.name, - base: getOverrideBranch() ?? parent.default_branch, - }; - } - - async getPullRequestDefaultRepo(): Promise { - const defaults = await this.getPullRequestDefaults(); - return this.findRepo(repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo) || this._githubRepositories[0]; - } - - async getMetadata(remote: string): Promise { - const repo = this.findRepo(byRemoteName(remote)); - return repo && repo.getMetadata(); - } - - async getHeadCommitMessage(): Promise { - const { repository } = this; - if (repository.state.HEAD && repository.state.HEAD.commit) { - const { message } = await repository.getCommit(repository.state.HEAD.commit); - return message; - } - - return ''; - } - - async getTipCommitMessage(branch: string): Promise { - Logger.debug(`Git tip message for branch ${branch} - enter`, this.id); - const { repository } = this; - let { commit } = await repository.getBranch(branch); - let message: string = ''; - let count = 0; - do { - if (commit) { - let fullCommit: Commit = await repository.getCommit(commit); - if (fullCommit.parents.length <= 1) { - message = fullCommit.message; - break; - } else { - commit = fullCommit.parents[0]; - } - } - count++; - } while (message === '' && commit && count < 5); - - - Logger.debug(`Git tip message for branch ${branch} - done`, this.id); - return message; - } - - async getOrigin(branch?: Branch): Promise { - if (!this._githubRepositories.length) { - throw new NoGitHubReposError(this.repository); - } - - const upstreamRef = branch ? branch.upstream : this.upstreamRef; - if (upstreamRef) { - // If our current branch has an upstream ref set, find its GitHubRepository. - const upstream = this.findRepo(byRemoteName(upstreamRef.remote)); - - // If the upstream wasn't listed in the remotes setting, create a GitHubRepository - // object for it if is does point to GitHub. - if (!upstream) { - const remote = (await this.getAllGitHubRemotes()).find(r => r.remoteName === upstreamRef.remote); - if (remote) { - return this.createAndAddGitHubRepository(remote, this._credentialStore); - } - - Logger.error(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); - - // No GitHubRepository? We currently won't try pushing elsewhere, - // so fail. - throw new BadUpstreamError(this.repository.state.HEAD!.name!, upstreamRef, 'is not a GitHub repo'); - } - - // Otherwise, we'll push upstream. - return upstream; - } - - // If no upstream is set, let's go digging. - const [first, ...rest] = this._githubRepositories; - return !rest.length // Is there only one GitHub remote? - ? first // I GUESS THAT'S WHAT WE'RE GOING WITH, THEN. - : // Otherwise, let's try... - this.findRepo(byRemoteName('origin')) || // by convention - this.findRepo(ownedByMe) || // bc maybe we can push there - first; // out of raw desperation - } - - findRepo(where: Predicate): GitHubRepository | undefined { - return this._githubRepositories.filter(where)[0]; - } - - get upstreamRef(): UpstreamRef | undefined { - const { HEAD } = this.repository.state; - return HEAD && HEAD.upstream; - } - - async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { - const repo = this._githubRepositories.find( - r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, - ); - if (!repo) { - throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); - } - - let pullRequestModel: PullRequestModel | undefined; - try { - pullRequestModel = await repo.createPullRequest(params); - - const branchNameSeparatorIndex = params.head.indexOf(':'); - const branchName = params.head.slice(branchNameSeparatorIndex + 1); - await PullRequestGitHelper.associateBranchWithPullRequest(this._repository, pullRequestModel, branchName); - - /* __GDPR__ - "pr.create.success" : { - "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); - return pullRequestModel; - } catch (e) { - if (e.message.indexOf('No commits between ') > -1) { - // There are unpushed commits - if (this._repository.state.HEAD?.ahead) { - // Offer to push changes - const pushCommits = vscode.l10n.t({ message: 'Push Commits', comment: 'Pushes the local commits to the remote.' }); - const shouldPush = await vscode.window.showInformationMessage( - vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to push your local commits and create the pull request?', params.base, params.head), - { modal: true }, - pushCommits, - ); - if (shouldPush === pushCommits) { - await this._repository.push(); - return this.createPullRequest(params); - } else { - return; - } - } - - // There are uncommitted changes - if (this._repository.state.workingTreeChanges.length || this._repository.state.indexChanges.length) { - const commitChanges = vscode.l10n.t('Commit Changes'); - const shouldCommit = await vscode.window.showInformationMessage( - vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to commit your changes and create the pull request?', params.base, params.head), - { modal: true }, - commitChanges, - ); - if (shouldCommit === commitChanges) { - await this._repository.add(this._repository.state.indexChanges.map(change => change.uri.fsPath)); - await this.repository.commit(`${params.title}${params.body ? `\n${params.body}` : ''}`); - await this._repository.push(); - return this.createPullRequest(params); - } else { - return; - } - } - } - - Logger.error(`Creating pull requests failed: ${e}`, this.id); - - /* __GDPR__ - "pr.create.failure" : { - "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('pr.create.failure', { - isDraft: (params.draft || '').toString(), - }); - - if (pullRequestModel) { - // We have created the pull request but something else failed (ex., modifying the git config) - // We shouldn't show an error as the pull request was successfully created - return pullRequestModel; - } - throw new Error(formatError(e)); - } - } - - async createIssue(params: OctokitCommon.IssuesCreateParams): Promise { - try { - const repo = this._githubRepositories.find( - r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, - ); - if (!repo) { - throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); - } - - await repo.ensure(); - - // Create PR - const { data } = await repo.octokit.call(repo.octokit.api.issues.create, params); - const item = convertRESTIssueToRawPullRequest(data, repo); - const issueModel = new IssueModel(repo, repo.remote, item); - - /* __GDPR__ - "issue.create.success" : { - } - */ - this.telemetry.sendTelemetryEvent('issue.create.success'); - return issueModel; - } catch (e) { - Logger.error(` Creating issue failed: ${e}`, this.id); - - /* __GDPR__ - "issue.create.failure" : {} - */ - this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); - vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); - } - - return undefined; - } - - async assignIssue(issue: IssueModel, login: string): Promise { - try { - const repo = this._githubRepositories.find( - r => r.remote.owner === issue.remote.owner && r.remote.repositoryName === issue.remote.repositoryName, - ); - if (!repo) { - throw new Error( - `No matching repository ${issue.remote.repositoryName} found for ${issue.remote.owner}`, - ); - } - - await repo.ensure(); - - const param: OctokitCommon.IssuesAssignParams = { - assignees: [login], - owner: issue.remote.owner, - repo: issue.remote.repositoryName, - issue_number: issue.number, - }; - await repo.octokit.call(repo.octokit.api.issues.addAssignees, param); - - /* __GDPR__ - "issue.assign.success" : { - } - */ - this.telemetry.sendTelemetryEvent('issue.assign.success'); - } catch (e) { - Logger.error(`Assigning issue failed: ${e}`, this.id); - - /* __GDPR__ - "issue.assign.failure" : { - } - */ - this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); - vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); - } - } - - getCurrentUser(githubRepository?: GitHubRepository): Promise { - if (!githubRepository) { - githubRepository = this.gitHubRepositories[0]; - } - return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); - } - - async mergePullRequest( - pullRequest: PullRequestModel, - title?: string, - description?: string, - method?: 'merge' | 'squash' | 'rebase', - ): Promise<{ merged: boolean, message: string }> { - const { octokit, remote } = await pullRequest.githubRepository.ensure(); - - const activePRSHA = this.activePullRequest && this.activePullRequest.head && this.activePullRequest.head.sha; - const workingDirectorySHA = this.repository.state.HEAD && this.repository.state.HEAD.commit; - const mergingPRSHA = pullRequest.head && pullRequest.head.sha; - const workingDirectoryIsDirty = this.repository.state.workingTreeChanges.length > 0; - - if (activePRSHA === mergingPRSHA) { - // We're on the branch of the pr being merged. - - if (workingDirectorySHA !== mergingPRSHA) { - // We are looking at different commit than what will be merged - const { ahead } = this.repository.state.HEAD!; - const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this PR branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); - const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this PR branch.\n\nWould you like to proceed anyway?'); - if (ahead && - (await vscode.window.showWarningMessage( - ahead > 1 ? pluralMessage : singularMessage, - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined) { - - return { - merged: false, - message: 'unpushed changes', - }; - } - } - - if (workingDirectoryIsDirty) { - // We have made changes to the PR that are not committed - if ( - (await vscode.window.showWarningMessage( - vscode.l10n.t('You have uncommitted changes on this PR branch.\n\n Would you like to proceed anyway?'), - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined - ) { - return { - merged: false, - message: 'uncommitted changes', - }; - } - } - } - - return octokit.call(octokit.api.pulls.merge, { - commit_message: description, - commit_title: title, - merge_method: - method || - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'merge' | 'squash' | 'rebase'>(DEFAULT_MERGE_METHOD), - owner: remote.owner, - repo: remote.repositoryName, - pull_number: pullRequest.number, - }) - .then(x => { - /* __GDPR__ - "pr.merge.success" : {} - */ - this.telemetry.sendTelemetryEvent('pr.merge.success'); - this._onDidMergePullRequest.fire(); - return x.data; - }) - .catch(e => { - /* __GDPR__ - "pr.merge.failure" : {} - */ - this.telemetry.sendTelemetryErrorEvent('pr.merge.failure'); - throw e; - }); - } - - async deleteBranch(pullRequest: PullRequestModel) { - await pullRequest.githubRepository.deleteBranch(pullRequest); - } - - private async getBranchDeletionItems() { - const allConfigs = await this.repository.getConfigs(); - const branchInfos: Map = new Map(); - - allConfigs.forEach(config => { - const key = config.key; - const matches = /^branch\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const branchName = matches[1]; - - if (!branchInfos.has(branchName)) { - branchInfos.set(branchName, {}); - } - - const value = branchInfos.get(branchName); - if (matches[2] === 'remote') { - value!['remote'] = config.value; - } - - if (matches[2] === 'github-pr-owner-number') { - const metadata = PullRequestGitHelper.parsePullRequestMetadata(config.value); - value!['metadata'] = metadata; - } - - branchInfos.set(branchName, value!); - } - }); - - const actions: (vscode.QuickPickItem & { metadata: PullRequestMetadata; legacy?: boolean })[] = []; - branchInfos.forEach((value, key) => { - if (value.metadata) { - const activePRUrl = this.activePullRequest && this.activePullRequest.base.repositoryCloneUrl; - const matchesActiveBranch = activePRUrl - ? activePRUrl.owner === value.metadata.owner && - activePRUrl.repositoryName === value.metadata.repositoryName && - this.activePullRequest && - this.activePullRequest.number === value.metadata.prNumber - : false; - - if (!matchesActiveBranch) { - actions.push({ - label: `${key}`, - description: `${value.metadata!.repositoryName}/${value.metadata!.owner} #${value.metadata.prNumber - }`, - picked: false, - metadata: value.metadata!, - }); - } - } - }); - - const results = await Promise.all( - actions.map(async action => { - const metadata = action.metadata; - const githubRepo = this._githubRepositories.find( - repo => - repo.remote.owner.toLowerCase() === metadata!.owner.toLowerCase() && - repo.remote.repositoryName.toLowerCase() === metadata!.repositoryName.toLowerCase(), - ); - - if (!githubRepo) { - return action; - } - - const { remote, query, schema } = await githubRepo.ensure(); - try { - const { data } = await query({ - query: schema.PullRequestState, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: metadata!.prNumber, - }, - }); - - action.legacy = data.repository?.pullRequest.state !== 'OPEN'; - } catch { } - - return action; - }), - ); - - results.forEach(result => { - if (result.legacy) { - result.picked = true; - } else { - result.description = vscode.l10n.t('{0} is still Open', result.description!); - } - }); - - return results; - } - - public async cleanupAfterPullRequest(branchName: string, pullRequest: PullRequestModel) { - const defaults = await this.getPullRequestDefaults(); - if (branchName === defaults.base) { - Logger.debug('Not cleaning up default branch.', this.id); - return; - } - if (pullRequest.author.login === (await this.getCurrentUser()).login) { - Logger.debug('Not cleaning up user\'s branch.', this.id); - return; - } - const branch = await this.repository.getBranch(branchName); - const remote = branch.upstream?.remote; - try { - Logger.debug(`Cleaning up branch ${branchName}`, this.id); - await this.repository.deleteBranch(branchName); - } catch (e) { - // The branch probably had unpushed changes and cannot be deleted. - return; - } - if (!remote) { - return; - } - const remotes = await this.getDeleatableRemotes(undefined); - if (remotes.has(remote) && remotes.get(remote)!.createdForPullRequest) { - Logger.debug(`Cleaning up remote ${remote}`, this.id); - this.repository.removeRemote(remote); - } - } - - private async getDeleatableRemotes(nonExistantBranches?: Set) { - const newConfigs = await this.repository.getConfigs(); - const remoteInfos: Map< - string, - { branches: Set; url?: string; createdForPullRequest?: boolean } - > = new Map(); - - newConfigs.forEach(config => { - const key = config.key; - let matches = /^branch\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const branchName = matches[1]; - - if (matches[2] === 'remote') { - const remoteName = config.value; - - if (!remoteInfos.has(remoteName)) { - remoteInfos.set(remoteName, { branches: new Set() }); - } - - if (!nonExistantBranches?.has(branchName)) { - const value = remoteInfos.get(remoteName); - value!.branches.add(branchName); - } - } - } - - matches = /^remote\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const remoteName = matches[1]; - - if (!remoteInfos.has(remoteName)) { - remoteInfos.set(remoteName, { branches: new Set() }); - } - - const value = remoteInfos.get(remoteName); - - if (matches[2] === 'github-pr-remote') { - value!.createdForPullRequest = config.value === 'true'; - } - - if (matches[2] === 'url') { - value!.url = config.value; - } - } - }); - return remoteInfos; - } - - private async getRemoteDeletionItems(nonExistantBranches: Set) { - // check if there are remotes that should be cleaned - const remoteInfos = await this.getDeleatableRemotes(nonExistantBranches); - const remoteItems: (vscode.QuickPickItem & { remote: string })[] = []; - - remoteInfos.forEach((value, key) => { - if (value.branches.size === 0) { - let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); - if (value.url) { - description = description ? `${description} ${value.url}` : value.url; - } - - remoteItems.push({ - label: key, - description: description, - picked: value.createdForPullRequest, - remote: key, - }); - } - }); - - return remoteItems; - } - - async deleteLocalBranchesNRemotes() { - return new Promise(async resolve => { - const quickPick = vscode.window.createQuickPick(); - quickPick.canSelectMany = true; - quickPick.ignoreFocusOut = true; - quickPick.placeholder = vscode.l10n.t('Choose local branches you want to delete permanently'); - quickPick.show(); - quickPick.busy = true; - - // Check local branches - const results = await this.getBranchDeletionItems(); - const defaults = await this.getPullRequestDefaults(); - quickPick.items = results; - quickPick.selectedItems = results.filter(result => { - // Do not pick the default branch for the repo. - return result.picked && !((result.label === defaults.base) && (result.metadata.owner === defaults.owner) && (result.metadata.repositoryName === defaults.repo)); - }); - quickPick.busy = false; - - let firstStep = true; - quickPick.onDidAccept(async () => { - quickPick.busy = true; - - if (firstStep) { - const picks = quickPick.selectedItems; - const nonExistantBranches = new Set(); - if (picks.length) { - try { - await Promise.all( - picks.map(async pick => { - try { - await this.repository.deleteBranch(pick.label, true); - } catch (e) { - if ((typeof e.stderr === 'string') && (e.stderr as string).includes('not found')) { - // TODO: The git extension API doesn't support removing configs - // If that support is added we should remove the config as it is no longer useful. - nonExistantBranches.add(pick.label); - } else { - throw e; - } - } - })); - } catch (e) { - quickPick.hide(); - vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); - } - } - - firstStep = false; - const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); - - if (remoteItems && remoteItems.length) { - quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); - quickPick.items = remoteItems; - quickPick.selectedItems = remoteItems.filter(item => item.picked); - } else { - quickPick.hide(); - } - } else { - // delete remotes - const picks = quickPick.selectedItems; - if (picks.length) { - await Promise.all( - picks.map(async pick => { - await this.repository.removeRemote(pick.label); - }), - ); - } - quickPick.hide(); - } - quickPick.busy = false; - }); - - quickPick.onDidHide(() => { - resolve(); - }); - }); - } - - async getPullRequestRepositoryDefaultBranch(issue: IssueModel): Promise { - const branch = await issue.githubRepository.getDefaultBranch(); - return branch; - } - - async getPullRequestRepositoryAccessAndMergeMethods( - pullRequest: PullRequestModel, - ): Promise { - const mergeOptions = await pullRequest.githubRepository.getRepoAccessAndMergeMethods(); - return mergeOptions; - } - - async mergeQueueMethodForBranch(branch: string, owner: string, repoName: string): Promise { - return (await this.gitHubRepositories.find(repository => repository.remote.owner === owner && repository.remote.repositoryName === repoName)?.mergeQueueMethodForBranch(branch)); - } - - async fulfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { - try { - if (!pullRequest.isResolved()) { - return; - } - - Logger.debug(`Fulfill pull request missing info - start`, this.id); - const githubRepository = pullRequest.githubRepository; - const { octokit, remote } = await githubRepository.ensure(); - - if (!pullRequest.base) { - const { data } = await octokit.call(octokit.api.pulls.get, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: pullRequest.number, - }); - pullRequest.update(convertRESTPullRequestToRawPullRequest(data, githubRepository)); - } - - if (!pullRequest.mergeBase) { - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, - head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, - }); - - pullRequest.mergeBase = data.merge_base_commit.sha; - } - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); - } - Logger.debug(`Fulfill pull request missing info - done`, this.id); - } - - //#region Git related APIs - - private async resolveItem(owner: string, repositoryName: string): Promise { - let githubRepo = this._githubRepositories.find(repo => { - const ret = - repo.remote.owner.toLowerCase() === owner.toLowerCase() && - repo.remote.repositoryName.toLowerCase() === repositoryName.toLowerCase(); - return ret; - }); - - if (!githubRepo) { - Logger.appendLine(`GitHubRepository not found: ${owner}/${repositoryName}`, this.id); - // try to create the repository - githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - } - return githubRepo; - } - - async resolvePullRequest( - owner: string, - repositoryName: string, - pullRequestNumber: number, - ): Promise { - const githubRepo = await this.resolveItem(owner, repositoryName); - Logger.appendLine(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); - if (githubRepo) { - const pr = await githubRepo.getPullRequest(pullRequestNumber); - Logger.appendLine(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); - if (pr) { - if (await githubRepo.hasBranch(pr.base.name)) { - return pr; - } - } - } - return undefined; - } - - async resolveIssue( - owner: string, - repositoryName: string, - pullRequestNumber: number, - withComments: boolean = false, - ): Promise { - const githubRepo = await this.resolveItem(owner, repositoryName); - if (githubRepo) { - return githubRepo.getIssue(pullRequestNumber, withComments); - } - return undefined; - } - - async resolveUser(owner: string, repositoryName: string, login: string): Promise { - Logger.debug(`Fetch user ${login}`, this.id); - const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - const { query, schema } = await githubRepository.ensure(); - - try { - const { data } = await query({ - query: schema.GetUser, - variables: { - login, - }, - }); - return parseGraphQLUser(data, githubRepository); - } catch (e) { - // Ignore cases where the user doesn't exist - if (!(e.message as (string | undefined))?.startsWith('GraphQL error: Could not resolve to a User with the login of')) { - Logger.warn(e.message); - } - } - return undefined; - } - - async getMatchingPullRequestMetadataForBranch() { - if (!this.repository || !this.repository.state.HEAD || !this.repository.state.HEAD.name) { - return null; - } - - const matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( - this.repository, - this.repository.state.HEAD.name, - ); - return matchingPullRequestMetadata; - } - - async getMatchingPullRequestMetadataFromGitHub(branch: Branch, remoteName?: string, remoteUrl?: string, upstreamBranchName?: string): Promise< - (PullRequestMetadata & { model: PullRequestModel }) | null - > { - try { - if (remoteName) { - return this.getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName, upstreamBranchName); - } - return this.getMatchingPullRequestMetadataFromGitHubWithUrl(branch, remoteUrl, upstreamBranchName); - } catch (e) { - Logger.error(`Unable to get matching pull request metadata from GitHub: ${e}`, this.id); - return null; - } - } - - async getMatchingPullRequestMetadataFromGitHubWithUrl(branch: Branch, remoteUrl?: string, upstreamBranchName?: string): Promise< - (PullRequestMetadata & { model: PullRequestModel }) | null - > { - if (!remoteUrl) { - return null; - } - let headGitHubRepo = this.gitHubRepositories.find(repo => repo.remote.url.toLowerCase() === remoteUrl.toLowerCase()); - let protocol: Protocol | undefined; - if (!headGitHubRepo && this.gitHubRepositories.length > 0) { - protocol = new Protocol(remoteUrl); - const remote = parseRemote(protocol.repositoryName, remoteUrl, protocol); - if (remote) { - headGitHubRepo = await this.createGitHubRepository(remote, this.credentialStore, true, true); - } - } - const matchingPR = await this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); - if (matchingPR && (branch.upstream === undefined) && protocol && headGitHubRepo && branch.name) { - const newRemote = await PullRequestGitHelper.createRemote(this.repository, headGitHubRepo?.remote, protocol); - const trackedBranchName = `refs/remotes/${newRemote}/${matchingPR.model.head?.name}`; - await this.repository.fetch({ remote: newRemote, ref: matchingPR.model.head?.name }); - await this.repository.setBranchUpstream(branch.name, trackedBranchName); - } - - return matchingPR; - } - - async getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName?: string, upstreamBranchName?: string): Promise< - (PullRequestMetadata & { model: PullRequestModel }) | null - > { - if (!remoteName) { - return null; - } - - const headGitHubRepo = this.gitHubRepositories.find( - repo => repo.remote.remoteName === remoteName, - ); - - return this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); - } - - private async doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo?: GitHubRepository, upstreamBranchName?: string): Promise< - (PullRequestMetadata & { model: PullRequestModel }) | null - > { - if (!headGitHubRepo || !upstreamBranchName) { - return null; - } - - const headRepoMetadata = await headGitHubRepo?.getMetadata(); - if (!headRepoMetadata?.owner) { - return null; - } - - const parentRepos = this.gitHubRepositories.filter(repo => { - if (headRepoMetadata.fork) { - return repo.remote.owner === headRepoMetadata.parent?.owner?.login && repo.remote.repositoryName === headRepoMetadata.parent.name; - } else { - return repo.remote.owner === headRepoMetadata.owner?.login && repo.remote.repositoryName === headRepoMetadata.name; - } - }); - - // Search through each github repo to see if it has a PR with this head branch. - for (const repo of parentRepos) { - const matchingPullRequest = await repo.getPullRequestForBranch(upstreamBranchName, headRepoMetadata.owner.login); - if (matchingPullRequest) { - return { - owner: repo.remote.owner, - repositoryName: repo.remote.repositoryName, - prNumber: matchingPullRequest.number, - model: matchingPullRequest, - }; - } - } - return null; - } - - async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest, progress); - } - - async getBranchNameForPullRequest(pullRequest: PullRequestModel) { - return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, pullRequest); - } - - async fetchAndCheckout(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest, progress); - } - - async checkout(branchName: string): Promise { - return this.repository.checkout(branchName); - } - - async fetchById(githubRepo: GitHubRepository, id: number): Promise { - const pullRequest = await githubRepo.getPullRequest(id); - if (pullRequest) { - return pullRequest; - } else { - vscode.window.showErrorMessage(vscode.l10n.t('Pull request number {0} does not exist in {1}', id, `${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`), { modal: true }); - } - } - - public async checkoutDefaultBranch(branch: string): Promise { - let branchObj: Branch | undefined; - try { - branchObj = await this.repository.getBranch(branch); - - const currentBranch = this.repository.state.HEAD?.name; - if (currentBranch === branchObj.name) { - const chooseABranch = vscode.l10n.t('Choose a Branch'); - vscode.window.showInformationMessage(vscode.l10n.t('The default branch is already checked out.'), chooseABranch).then(choice => { - if (choice === chooseABranch) { - return git.checkout(); - } - }); - return; - } - - // respect the git setting to fetch before checkout - if (vscode.workspace.getConfiguration(GIT).get(PULL_BEFORE_CHECKOUT, false) && branchObj.upstream) { - await this.repository.fetch({ remote: branchObj.upstream.remote, ref: `${branchObj.upstream.name}:${branchObj.name}` }); - } - - if (branchObj.upstream && branch === branchObj.upstream.name) { - await this.repository.checkout(branch); - } else { - await git.checkout(); - } - - const fileClose: Thenable[] = []; - // Close the PR description and any open review scheme files. - for (const tabGroup of vscode.window.tabGroups.all) { - for (const tab of tabGroup.tabs) { - let uri: vscode.Uri | string | undefined; - if (tab.input instanceof vscode.TabInputText) { - uri = tab.input.uri; - } else if (tab.input instanceof vscode.TabInputTextDiff) { - uri = tab.input.original; - } else if (tab.input instanceof vscode.TabInputWebview) { - uri = tab.input.viewType; - } - if ((uri instanceof vscode.Uri && uri.scheme === Schemes.Review) || (typeof uri === 'string' && uri.endsWith(PULL_REQUEST_OVERVIEW_VIEW_TYPE))) { - fileClose.push(vscode.window.tabGroups.close(tab)); - } - } - } - await Promise.all(fileClose); - } catch (e) { - if (e.gitErrorCode) { - // for known git errors, we should provide actions for users to continue. - if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - vscode.window.showErrorMessage( - vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), - ); - return; - } - } - Logger.error(`Exiting failed: ${e}. Target branch ${branch} used to find branch ${branchObj?.name ?? 'unknown'} with upstream ${branchObj?.upstream?.name ?? 'unknown'}.`); - vscode.window.showErrorMessage(`Exiting failed: ${e}`); - } - } - - private async pullBranchConfiguration(): Promise<'never' | 'prompt' | 'always'> { - const neverShowPullNotification = this.context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); - if (neverShowPullNotification) { - this.context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, false); - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); - } - return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt'); - } - - private async pullBranch(branch: Branch) { - if (this._repository.state.HEAD?.name === branch.name) { - await this._repository.pull(); - } - } - - private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { - if (!this._updateMessageShown || autoStashSetting) { - this._updateMessageShown = true; - const pull = vscode.l10n.t('Pull'); - const always = vscode.l10n.t('Always Pull'); - const never = vscode.l10n.t('Never Show Again'); - const options = [pull]; - if (!autoStashSetting) { - options.push(always, never); - } - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('There are updates available for pull request {0}.', `${pr.number}: ${pr.title}`), - {}, - ...options - ); - - if (result === pull) { - await this.pullBranch(branch); - this._updateMessageShown = false; - } else if (never) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); - } else if (always) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'always', vscode.ConfigurationTarget.Global); - await this.pullBranch(branch); - } - } - } - - private _updateMessageShown: boolean = false; - public async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel, shouldFetch: boolean): Promise { - if (this.activePullRequest?.id !== pr.id) { - return; - } - const branch = this._repository.state.HEAD; - if (branch) { - const remote = branch.upstream ? branch.upstream.remote : null; - const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; - if (remote) { - try { - if (shouldFetch && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ALLOW_FETCH, true)) { - await this._repository.fetch(remote, remoteBranch); - } - } catch (e) { - if (e.stderr) { - if ((e.stderr as string).startsWith('fatal: couldn\'t find remote ref')) { - // We've managed to check out the PR, but the remote has been deleted. This is fine, but we can't fetch now. - } else { - vscode.window.showErrorMessage(vscode.l10n.t('An error occurred when fetching the repository: {0}', e.stderr)); - } - } - Logger.error(`Error when fetching: ${e.stderr ?? e}`, this.id); - } - const pullBranchConfiguration = await this.pullBranchConfiguration(); - if (branch.behind !== undefined && branch.behind > 0) { - switch (pullBranchConfiguration) { - case 'always': { - const autoStash = vscode.workspace.getConfiguration(GIT).get(AUTO_STASH, false); - if (autoStash) { - return this.promptPullBrach(pr, branch, autoStash); - } else { - return this.pullBranch(branch); - } - } - case 'prompt': { - return this.promptPullBrach(pr, branch); - } - case 'never': return; - } - } - - } - } - } - - private findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { - return this._githubRepositories.find( - r => - (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) - && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) - && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), - ); - } - - private async createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean) { - const repo = new GitHubRepository(GitHubRemote.remoteAsGitHub(remote, await this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), this.repository.rootUri, credentialStore, this.telemetry, silent); - this._githubRepositories.push(repo); - return repo; - } - - private _createGitHubRepositoryBulkhead = bulkhead(1, 300); - async createGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean, ignoreRemoteName: boolean = false): Promise { - // Use a bulkhead/semaphore to ensure that we don't create multiple GitHubRepositories for the same remote at the same time. - return this._createGitHubRepositoryBulkhead.execute(() => { - return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName }) ?? - this.createAndAddGitHubRepository(remote, credentialStore, silent); - }); - } - - async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { - const existing = this.findExistingGitHubRepository({ owner, repositoryName }); - if (existing) { - return existing; - } - const gitRemotes = parseRepositoryRemotes(this.repository); - const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); - const uri = gitRemote?.url ?? `https://github.com/${owner}/${repositoryName}`; - return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); - } - - async findUpstreamForItem(item: { - remote: Remote; - githubRepository: GitHubRepository; - }): Promise<{ needsFork: boolean; upstream?: GitHubRepository; remote?: Remote }> { - let upstream: GitHubRepository | undefined; - let existingForkRemote: Remote | undefined; - for (const githubRepo of this.gitHubRepositories) { - if ( - !upstream && - githubRepo.remote.owner === item.remote.owner && - githubRepo.remote.repositoryName === item.remote.repositoryName - ) { - upstream = githubRepo; - continue; - } - const forkDetails = await githubRepo.getRepositoryForkDetails(); - if ( - forkDetails && - forkDetails.isFork && - forkDetails.parent.owner.login === item.remote.owner && - forkDetails.parent.name === item.remote.repositoryName - ) { - const foundforkPermission = await githubRepo.getViewerPermission(); - if ( - foundforkPermission === ViewerPermission.Admin || - foundforkPermission === ViewerPermission.Maintain || - foundforkPermission === ViewerPermission.Write - ) { - existingForkRemote = githubRepo.remote; - break; - } - } - } - let needsFork = false; - if (upstream && !existingForkRemote) { - const permission = await item.githubRepository.getViewerPermission(); - if ( - permission === ViewerPermission.Read || - permission === ViewerPermission.Triage || - permission === ViewerPermission.Unknown - ) { - needsFork = true; - } - } - return { needsFork, upstream, remote: existingForkRemote }; - } - - async forkWithProgress( - progress: vscode.Progress<{ message?: string; increment?: number }>, - githubRepository: GitHubRepository, - repoString: string, - matchingRepo: Repository, - ): Promise { - progress.report({ message: vscode.l10n.t('Forking {0}...', repoString) }); - const result = await githubRepository.fork(); - progress.report({ increment: 50 }); - if (!result) { - vscode.window.showErrorMessage( - vscode.l10n.t('Unable to create a fork of {0}. Check that your GitHub credentials are correct.', repoString), - ); - return; - } - - const workingRemoteName: string = - matchingRepo.state.remotes.length > 1 ? 'origin' : matchingRepo.state.remotes[0].name; - progress.report({ message: vscode.l10n.t('Adding remotes. This may take a few moments.') }); - await matchingRepo.renameRemote(workingRemoteName, 'upstream'); - await matchingRepo.addRemote(workingRemoteName, result); - // Now the extension is responding to all the git changes. - await new Promise(resolve => { - if (this.gitHubRepositories.length === 0) { - const disposable = this.onDidChangeRepositories(() => { - if (this.gitHubRepositories.length > 0) { - disposable.dispose(); - resolve(); - } - }); - } else { - resolve(); - } - }); - progress.report({ increment: 50 }); - return workingRemoteName; - } - - async doFork( - githubRepository: GitHubRepository, - repoString: string, - matchingRepo: Repository, - ): Promise { - return vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating Fork') }, - async progress => { - try { - return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); - } catch (e) { - vscode.window.showErrorMessage(`Creating fork failed: ${e}`); - } - return undefined; - }, - ); - } - - async tryOfferToFork(githubRepository: GitHubRepository): Promise { - const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; - - const fork = vscode.l10n.t('Fork'); - const dontFork = vscode.l10n.t('Don\'t Fork'); - const response = await vscode.window.showInformationMessage( - vscode.l10n.t('You don\'t have permission to push to {0}. Do you want to fork {0}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to {0}.', repoString), - { modal: true }, - fork, - dontFork, - ); - switch (response) { - case fork: { - return this.doFork(githubRepository, repoString, this.repository); - } - case dontFork: - return false; - default: - return undefined; - } - } - - public getTitleAndDescriptionProvider(searchTerm?: string) { - return this._git.getTitleAndDescriptionProvider(searchTerm); - } - - dispose() { - this._subs.forEach(sub => sub.dispose()); - this._onDidDispose.fire(); - } -} - -export function getEventType(text: string) { - switch (text) { - case 'committed': - return EventType.Committed; - case 'mentioned': - return EventType.Mentioned; - case 'subscribed': - return EventType.Subscribed; - case 'commented': - return EventType.Commented; - case 'reviewed': - return EventType.Reviewed; - default: - return EventType.Other; - } -} - -const ownedByMe: Predicate = repo => { - const { currentUser = null } = repo.octokit as any; - return currentUser && repo.remote.owner === currentUser.login; -}; - -export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => - remoteName === name; - -export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { - const message = await promise; - if (!message) { - return; - } - const idxLineBreak = message.indexOf('\n'); - return { - title: idxLineBreak === -1 ? message : message.substr(0, idxLineBreak), - - body: idxLineBreak === -1 ? '' : message.slice(idxLineBreak + 1).trim(), - }; -}; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { bulkhead } from 'cockatiel'; +import * as vscode from 'vscode'; +import type { Branch, Commit, Repository, UpstreamRef } from '../api/api'; +import { GitApiImpl, GitErrorCodes } from '../api/api1'; +import { GitHubManager } from '../authentication/githubServer'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { Protocol, ProtocolType } from '../common/protocol'; +import { GitHubRemote, parseRemote, parseRepositoryRemotes, Remote } from '../common/remote'; +import { + ALLOW_FETCH, + AUTO_STASH, + DEFAULT_MERGE_METHOD, + GIT, + PR_SETTINGS_NAMESPACE, + PULL_BEFORE_CHECKOUT, + PULL_BRANCH, + REMOTES, + UPSTREAM_REMOTE, +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { EventType } from '../common/timelineEvent'; +import { Schemes } from '../common/uri'; +import { formatError, Predicate } from '../common/utils'; +import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; +import { git } from '../gitProviders/gitCommands'; +import { OctokitCommon } from './common'; +import { CredentialStore } from './credentials'; +import { GitHubRepository, ItemsData, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; +import { PullRequestState, UserResponse } from './graphql'; +import { IAccount, ILabel, IMilestone, IProject, IPullRequestsPagingOptions, Issue, ITeam, MergeMethod, PRType, RepoAccessAndMergeMethods, User } from './interface'; +import { IssueModel } from './issueModel'; +import { MilestoneModel } from './milestoneModel'; +import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { + convertRESTIssueToRawPullRequest, + convertRESTPullRequestToRawPullRequest, + getOverrideBranch, + loginComparator, + parseGraphQLUser, + teamComparator, + variableSubstitution, +} from './utils'; + +interface PageInformation { + pullRequestPage: number; + hasMorePages: boolean | null; +} + +export interface ItemsResponseResult { + items: T[]; + hasMorePages: boolean; + hasUnsearchedRepositories: boolean; +} + +export class NoGitHubReposError extends Error { + constructor(public repository: Repository) { + super(); + } + + get message() { + return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); + } +} + +export class DetachedHeadError extends Error { + constructor(public repository: Repository) { + super(); + } + + get message() { + return vscode.l10n.t('{0} has a detached HEAD (create a branch first', this.repository.rootUri.toString()); + } +} + +export class BadUpstreamError extends Error { + constructor(public branchName: string, public upstreamRef: UpstreamRef, public problem: string) { + super(); + } + + get message() { + const { + upstreamRef: { remote, name }, + branchName, + problem, + } = this; + return vscode.l10n.t('The upstream ref {0} for branch {1} {2}.', `${remote}/${name}`, branchName, problem); + } +} + +export const ReposManagerStateContext: string = 'ReposManagerStateContext'; + +export enum ReposManagerState { + Initializing = 'Initializing', + NeedsAuthentication = 'NeedsAuthentication', + RepositoriesLoaded = 'RepositoriesLoaded', +} + +export interface PullRequestDefaults { + owner: string; + repo: string; + base: string; +} + +export const NO_MILESTONE: string = 'No Milestone'; + +enum PagedDataType { + PullRequest, + Milestones, + IssuesWithoutMilestone, + IssueSearch, +} + +const CACHED_TEMPLATE_URI = 'templateUri'; + +export class FolderRepositoryManager implements vscode.Disposable { + static ID = 'FolderRepositoryManager'; + + private _subs: vscode.Disposable[]; + private _activePullRequest?: PullRequestModel; + private _activeIssue?: IssueModel; + private _githubRepositories: GitHubRepository[]; + private _allGitHubRemotes: GitHubRemote[] = []; + private _mentionableUsers?: { [key: string]: IAccount[] }; + private _fetchMentionableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; + private _assignableUsers?: { [key: string]: IAccount[] }; + private _teamReviewers?: { [key: string]: ITeam[] }; + private _fetchAssignableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; + private _fetchTeamReviewersPromise?: Promise<{ [key: string]: ITeam[] }>; + private _gitBlameCache: { [key: string]: string } = {}; + private _githubManager: GitHubManager; + private _repositoryPageInformation: Map = new Map(); + private _addedUpstreamCount: number = 0; + + private _onDidMergePullRequest = new vscode.EventEmitter(); + readonly onDidMergePullRequest = this._onDidMergePullRequest.event; + + private _onDidChangeActivePullRequest = new vscode.EventEmitter<{ new: number | undefined, old: number | undefined }>(); + readonly onDidChangeActivePullRequest: vscode.Event<{ new: number | undefined, old: number | undefined }> = this._onDidChangeActivePullRequest.event; + private _onDidChangeActiveIssue = new vscode.EventEmitter(); + readonly onDidChangeActiveIssue: vscode.Event = this._onDidChangeActiveIssue.event; + + private _onDidLoadRepositories = new vscode.EventEmitter(); + readonly onDidLoadRepositories: vscode.Event = this._onDidLoadRepositories.event; + + private _onDidChangeRepositories = new vscode.EventEmitter(); + readonly onDidChangeRepositories: vscode.Event = this._onDidChangeRepositories.event; + + private _onDidChangeAssignableUsers = new vscode.EventEmitter(); + readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; + + private _onDidChangeGithubRepositories = new vscode.EventEmitter(); + readonly onDidChangeGithubRepositories: vscode.Event = this._onDidChangeGithubRepositories.event; + + private _onDidDispose = new vscode.EventEmitter(); + readonly onDidDispose: vscode.Event = this._onDidDispose.event; + + constructor( + private _id: number, + public context: vscode.ExtensionContext, + private _repository: Repository, + public readonly telemetry: ITelemetry, + private _git: GitApiImpl, + private _credentialStore: CredentialStore, + ) { + this._subs = []; + this._githubRepositories = []; + this._githubManager = new GitHubManager(); + + this._subs.push( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${REMOTES}`)) { + await this.updateRepositories(); + } + }), + ); + + this._subs.push(_credentialStore.onDidInitialize(() => this.updateRepositories())); + + this.cleanStoredRepoState(); + } + + private cleanStoredRepoState() { + const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; + const reposState = this.context.globalState.get(REPO_KEYS); + if (reposState?.repos) { + let keysChanged = false; + Object.keys(reposState.repos).forEach(repo => { + const repoState = reposState.repos[repo]; + if ((repoState.stateModifiedTime ?? 0) < deleteDate) { + keysChanged = true; + delete reposState.repos[repo]; + } + }); + if (keysChanged) { + this.context.globalState.update(REPO_KEYS, reposState); + } + } + } + + private get id(): string { + return `${FolderRepositoryManager.ID}+${this._id}`; + } + + get gitHubRepositories(): GitHubRepository[] { + return this._githubRepositories; + } + + public async computeAllUnknownRemotes(): Promise { + const remotes = parseRepositoryRemotes(this.repository); + const potentialRemotes = remotes.filter(remote => remote.host); + const serverTypes = await Promise.all( + potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const unknownRemotes: Remote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] === GitHubServerType.None) { + unknownRemotes.push(potentialRemote); + } + i++; + } + return unknownRemotes; + } + + public async computeAllGitHubRemotes(): Promise { + const remotes = parseRepositoryRemotes(this.repository); + const potentialRemotes = remotes.filter(remote => remote.host); + const serverTypes = await Promise.all( + potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const githubRemotes: GitHubRemote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] !== GitHubServerType.None) { + githubRemotes.push(GitHubRemote.remoteAsGitHub(potentialRemote, serverTypes[i])); + } + i++; + } + return githubRemotes; + } + + public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + + if (!remotesSetting) { + Logger.error(`Unable to read remotes setting`); + return Promise.resolve([]); + } + + const missingRemotes = remotesSetting.filter(remote => { + return !allGitHubRemotes.some(repo => repo.remoteName === remote); + }); + + if (missingRemotes.length === remotesSetting.length) { + Logger.warn(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); + } else { + Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, this.id); + } + + Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, this.id); + + return remotesSetting + .map(remote => allGitHubRemotes.find(repo => repo.remoteName === remote)) + .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo); + } + + get activeIssue(): IssueModel | undefined { + return this._activeIssue; + } + + set activeIssue(issue: IssueModel | undefined) { + this._activeIssue = issue; + this._onDidChangeActiveIssue.fire(); + } + + get activePullRequest(): PullRequestModel | undefined { + return this._activePullRequest; + } + + set activePullRequest(pullRequest: PullRequestModel | undefined) { + if (pullRequest === this._activePullRequest) { + return; + } + const oldNumber = this._activePullRequest?.number; + if (this._activePullRequest) { + this._activePullRequest.isActive = false; + } + + if (pullRequest) { + pullRequest.isActive = true; + pullRequest.githubRepository.commentsHandler?.unregisterCommentController(pullRequest.number); + } + const newNumber = pullRequest?.number; + + this._activePullRequest = pullRequest; + this._onDidChangeActivePullRequest.fire({ old: oldNumber, new: newNumber }); + } + + get repository(): Repository { + return this._repository; + } + + set repository(repository: Repository) { + this._repository = repository; + } + + get credentialStore(): CredentialStore { + return this._credentialStore; + } + + /** + * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. + * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. + */ + public setFileViewedContext() { + const states = this.activePullRequest?.getViewedFileStates(); + if (states) { + commands.setContext(contexts.VIEWED_FILES, Array.from(states.viewed)); + commands.setContext(contexts.UNVIEWED_FILES, Array.from(states.unviewed)); + } else { + this.clearFileViewedContext(); + } + } + + private clearFileViewedContext() { + commands.setContext(contexts.VIEWED_FILES, []); + commands.setContext(contexts.UNVIEWED_FILES, []); + } + + public async loginAndUpdate() { + if (!this._credentialStore.isAnyAuthenticated()) { + const waitForRepos = new Promise(c => { + const onReposChange = this.onDidChangeRepositories(() => { + onReposChange.dispose(); + c(); + }); + }); + await this._credentialStore.login(AuthProvider.github); + await waitForRepos; + } + } + + private async getActiveRemotes(): Promise { + this._allGitHubRemotes = await this.computeAllGitHubRemotes(); + const activeRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); + + if (activeRemotes.length) { + await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', true); + Logger.appendLine(`Found GitHub remote for folder ${this.repository.rootUri.fsPath}`); + } else { + Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`); + } + + return activeRemotes; + } + + private _updatingRepositories: Promise | undefined; + async updateRepositories(silent: boolean = false): Promise { + if (this._updatingRepositories) { + await this._updatingRepositories; + } + this._updatingRepositories = this.doUpdateRepositories(silent); + return this._updatingRepositories; + } + + private checkForAuthMatch(activeRemotes: GitHubRemote[]): boolean { + // Check that our auth matches the remote. + let dotComCount = 0; + let enterpriseCount = 0; + for (const remote of activeRemotes) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComCount++; + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseCount++; + } + } + + let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise); + if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { + // good + } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + // also good + } else if (isAuthenticated) { + // Not good. We have a mismatch between auth type and server type. + isAuthenticated = false; + } + vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); + return isAuthenticated; + } + + private async doUpdateRepositories(silent: boolean): Promise { + if (this._git.state === 'uninitialized') { + Logger.appendLine('Cannot updates repositories as git is uninitialized'); + + return; + } + + const activeRemotes = await this.getActiveRemotes(); + const isAuthenticated = this.checkForAuthMatch(activeRemotes); + if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { + const areAllNeverGitHub = (await this.computeAllUnknownRemotes()).every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); + if (areAllNeverGitHub) { + this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); + return; + } + } + const repositories: GitHubRepository[] = []; + const resolveRemotePromises: Promise[] = []; + const oldRepositories: GitHubRepository[] = []; + this._githubRepositories.forEach(repo => oldRepositories.push(repo)); + + const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId)); + for (const remote of authenticatedRemotes) { + const repository = await this.createGitHubRepository(remote, this._credentialStore); + resolveRemotePromises.push(repository.resolveRemote()); + repositories.push(repository); + } + + return Promise.all(resolveRemotePromises).then(async (remoteResults: boolean[]) => { + const missingSaml: string[] = []; + for (let i = 0; i < remoteResults.length; i++) { + if (!remoteResults[i]) { + missingSaml.push(repositories[i].remote.owner); + } + } + if (missingSaml.length > 0) { + const result = await this._credentialStore.showSamlMessageAndAuth(missingSaml); + if (result.canceled) { + this.dispose(); + return; + } + } + + this._githubRepositories = repositories; + oldRepositories.filter(old => this._githubRepositories.indexOf(old) < 0).forEach(repo => repo.dispose()); + + const repositoriesChanged = + oldRepositories.length !== this._githubRepositories.length || + !oldRepositories.every(oldRepo => + this._githubRepositories.some(newRepo => newRepo.remote.equals(oldRepo.remote)), + ); + + if (repositoriesChanged) { + this._onDidChangeGithubRepositories.fire(this._githubRepositories); + } + + if (this._githubRepositories.length && repositoriesChanged) { + if (await this.checkIfMissingUpstream()) { + this.updateRepositories(silent); + return; + } + } + + if (this.activePullRequest) { + this.getMentionableUsers(repositoriesChanged); + } + + this.getAssignableUsers(repositoriesChanged); + if (isAuthenticated && activeRemotes.length) { + this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); + } else if (!isAuthenticated) { + this._onDidLoadRepositories.fire(ReposManagerState.NeedsAuthentication); + } + if (!silent) { + this._onDidChangeRepositories.fire(); + } + return; + }); + } + + private async checkIfMissingUpstream(): Promise { + try { + const origin = await this.getOrigin(); + const metadata = await origin.getMetadata(); + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + if (metadata.fork && metadata.parent && (configuration.get<'add' | 'never'>(UPSTREAM_REMOTE, 'add') === 'add')) { + const parentUrl = new Protocol(metadata.parent.git_url); + const missingParentRemote = !this._githubRepositories.some( + repo => + repo.remote.owner === parentUrl.owner && + repo.remote.repositoryName === parentUrl.repositoryName, + ); + + if (missingParentRemote) { + const upstreamAvailable = !this.repository.state.remotes.some(remote => remote.name === 'upstream'); + const remoteName = upstreamAvailable ? 'upstream' : metadata.parent.owner?.login; + if (remoteName) { + // check the remotes to see what protocol is being used + const isSSH = this.gitHubRepositories[0].remote.gitProtocol.type === ProtocolType.SSH; + if (isSSH) { + await this.repository.addRemote(remoteName, metadata.parent.ssh_url); + } else { + await this.repository.addRemote(remoteName, metadata.parent.clone_url); + } + this._addedUpstreamCount++; + if (this._addedUpstreamCount > 1) { + // We've already added this remote, which means the user likely removed it. Let the user know they can disable this feature. + const neverOption = vscode.l10n.t('Set to `never`'); + vscode.window.showInformationMessage(vscode.l10n.t('An `upstream` remote has been added for this repository. You can disable this feature by setting `githubPullRequests.upstreamRemote` to `never`.'), neverOption) + .then(choice => { + if (choice === neverOption) { + configuration.update(UPSTREAM_REMOTE, 'never', vscode.ConfigurationTarget.Global); + } + }); + } + return true; + } + } + } + } catch (e) { + Logger.appendLine(`Missing upstream check failed: ${e}`); + // ignore + } + return false; + } + + getAllAssignableUsers(): IAccount[] | undefined { + if (this._assignableUsers) { + const allAssignableUsers: IAccount[] = []; + Object.keys(this._assignableUsers).forEach(k => { + allAssignableUsers.push(...this._assignableUsers![k]); + }); + + return allAssignableUsers; + } + + return undefined; + } + + private async getCachedFromGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects'): Promise<{ [key: string]: T[] } | undefined> { + Logger.appendLine(`Trying to use globalState for ${userKind}.`); + + const usersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + let usersCacheExists; + try { + usersCacheExists = await vscode.workspace.fs.stat(usersCacheLocation); + } catch (e) { + // file doesn't exit + } + if (!usersCacheExists) { + Logger.appendLine(`GlobalState does not exist for ${userKind}.`); + return undefined; + } + + const cache: { [key: string]: T[] } = {}; + const hasAllRepos = (await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(usersCacheLocation, key); + let repoSpecificCache; + let cacheAsJson; + try { + repoSpecificCache = await vscode.workspace.fs.readFile(repoSpecificFile); + cacheAsJson = JSON.parse(repoSpecificCache.toString()); + } catch (e) { + if (e instanceof Error && e.message.includes('Unexpected non-whitespace character after JSON')) { + Logger.error(`Error parsing ${userKind} cache for ${repo.remote.remoteName}.`); + } + // file doesn't exist + } + if (repoSpecificCache && repoSpecificCache.toString()) { + cache[repo.remote.remoteName] = cacheAsJson ?? []; + return true; + } + }))).every(value => value); + if (hasAllRepos) { + Logger.appendLine(`Using globalState ${userKind} for ${Object.keys(cache).length}.`); + return cache; + } + + Logger.appendLine(`No globalState for ${userKind}.`); + return undefined; + } + + private async saveInGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects', cache: { [key: string]: T[] }): Promise { + const cacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(cacheLocation, key); + await vscode.workspace.fs.writeFile(repoSpecificFile, new TextEncoder().encode(JSON.stringify(cache[repo.remote.remoteName]))); + })); + } + + private createFetchMentionableUsersPromise(): Promise<{ [key: string]: IAccount[] }> { + const cache: { [key: string]: IAccount[] } = {}; + return new Promise<{ [key: string]: IAccount[] }>(resolve => { + const promises = this._githubRepositories.map(async githubRepository => { + const data = await githubRepository.getMentionableUsers(); + cache[githubRepository.remote.remoteName] = data; + return; + }); + + Promise.all(promises).then(() => { + this._mentionableUsers = cache; + this._fetchMentionableUsersPromise = undefined; + this.saveInGlobalState('mentionableUsers', cache) + .then(() => resolve(cache)); + }); + }); + } + + async getMentionableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { + if (clearCache) { + delete this._mentionableUsers; + } + + if (this._mentionableUsers) { + Logger.appendLine('Using in-memory cached mentionable users.'); + return this._mentionableUsers; + } + + const globalStateMentionableUsers = await this.getCachedFromGlobalState('mentionableUsers'); + + if (!this._fetchMentionableUsersPromise) { + this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); + return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; + } + + return this._fetchMentionableUsersPromise; + } + + async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { + if (clearCache) { + delete this._assignableUsers; + } + + if (this._assignableUsers) { + Logger.appendLine('Using in-memory cached assignable users.'); + return this._assignableUsers; + } + + const globalStateAssignableUsers = await this.getCachedFromGlobalState('assignableUsers'); + + if (!this._fetchAssignableUsersPromise) { + const cache: { [key: string]: IAccount[] } = {}; + const allAssignableUsers: IAccount[] = []; + this._fetchAssignableUsersPromise = new Promise(resolve => { + const promises = this._githubRepositories.map(async githubRepository => { + const data = await githubRepository.getAssignableUsers(); + cache[githubRepository.remote.remoteName] = data.sort(loginComparator); + allAssignableUsers.push(...data); + return; + }); + + Promise.all(promises).then(() => { + this._assignableUsers = cache; + this._fetchAssignableUsersPromise = undefined; + this.saveInGlobalState('assignableUsers', cache); + resolve(cache); + this._onDidChangeAssignableUsers.fire(allAssignableUsers); + }); + }); + return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; + } + + return this._fetchAssignableUsersPromise; + } + + async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { + if (refreshKind === TeamReviewerRefreshKind.Force) { + delete this._teamReviewers; + } + + if (this._teamReviewers) { + Logger.appendLine('Using in-memory cached team reviewers.'); + return this._teamReviewers; + } + + const globalStateTeamReviewers = (refreshKind === TeamReviewerRefreshKind.Force) ? undefined : await this.getCachedFromGlobalState('teamReviewers'); + if (globalStateTeamReviewers) { + this._teamReviewers = globalStateTeamReviewers; + return globalStateTeamReviewers || {}; + } + + if (!this._fetchTeamReviewersPromise) { + const cache: { [key: string]: ITeam[] } = {}; + return (this._fetchTeamReviewersPromise = new Promise(async (resolve) => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgTeams: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgTeams.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgTeams(refreshKind); + orgTeams.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + const allTeamsForOrg = orgTeams.get(githubRepository.remote.owner) ?? []; + cache[githubRepository.remote.remoteName] = allTeamsForOrg.filter(team => team.repositoryNames.includes(githubRepository.remote.repositoryName)).sort(teamComparator); + } + + this._teamReviewers = cache; + this._fetchTeamReviewersPromise = undefined; + this.saveInGlobalState('teamReviewers', cache); + resolve(cache); + })); + } + + return this._fetchTeamReviewersPromise; + } + + private createFetchOrgProjectsPromise(): Promise<{ [key: string]: IProject[] }> { + const cache: { [key: string]: IProject[] } = {}; + return new Promise<{ [key: string]: IProject[] }>(async resolve => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgProjects: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgProjects.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgProjects(); + orgProjects.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + cache[githubRepository.remote.remoteName] = orgProjects.get(githubRepository.remote.owner) ?? []; + } + + await this.saveInGlobalState('orgProjects', cache); + resolve(cache); + }); + } + + async getOrgProjects(clearCache?: boolean): Promise<{ [key: string]: IProject[] }> { + if (clearCache) { + return this.createFetchOrgProjectsPromise(); + } + + const globalStateProjects = await this.getCachedFromGlobalState('orgProjects'); + return globalStateProjects ?? this.createFetchOrgProjectsPromise(); + } + + async getOrgTeamsCount(repository: GitHubRepository): Promise { + if ((await repository.getMetadata()).organization) { + return repository.getOrgTeamsCount(); + } + return 0; + } + + async getPullRequestParticipants(githubRepository: GitHubRepository, pullRequestNumber: number): Promise<{ participants: IAccount[], viewer: IAccount }> { + return { + participants: await githubRepository.getPullRequestParticipants(pullRequestNumber), + viewer: await this.getCurrentUser(githubRepository) + }; + } + + /** + * Returns the remotes that are currently active, which is those that are important by convention (origin, upstream), + * or the remotes configured by the setting githubPullRequests.remotes + */ + async getGitHubRemotes(): Promise { + const githubRepositories = this._githubRepositories; + + if (!githubRepositories || !githubRepositories.length) { + return []; + } + + const remotes = githubRepositories.map(repo => repo.remote).flat(); + + const serverTypes = await Promise.all( + remotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + + const githubRemotes = remotes.map((remote, index) => GitHubRemote.remoteAsGitHub(remote, serverTypes[index])); + if (this.checkForAuthMatch(githubRemotes)) { + return githubRemotes; + } + return []; + } + + /** + * Returns all remotes from the repository. + */ + async getAllGitHubRemotes(): Promise { + return await this.computeAllGitHubRemotes(); + } + + async getLocalPullRequests(): Promise { + const githubRepositories = this._githubRepositories; + + if (!githubRepositories || !githubRepositories.length) { + return []; + } + + const localBranches = (await this.repository.getRefs({ pattern: 'refs/heads/' })) + .filter(r => r.name !== undefined) + .map(r => r.name!); + + // Chunk localBranches into chunks of 100 to avoid hitting the GitHub API rate limit + const chunkedLocalBranches: string[][] = []; + const chunkSize = 100; + for (let i = 0; i < localBranches.length; i += chunkSize) { + const chunk = localBranches.slice(i, i + chunkSize); + chunkedLocalBranches.push(chunk); + } + + const models: (PullRequestModel | undefined)[] = []; + for (const chunk of chunkedLocalBranches) { + models.push(...await Promise.all(chunk.map(async localBranchName => { + const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + localBranchName, + ); + + if (matchingPRMetadata) { + const { owner, prNumber } = matchingPRMetadata; + const githubRepo = githubRepositories.find( + repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), + ); + + if (githubRepo) { + const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); + + if (pullRequest) { + pullRequest.localBranchName = localBranchName; + return pullRequest; + } + } + } + }))); + } + + return models.filter(value => value !== undefined) as PullRequestModel[]; + } + + async getLabels(issue?: IssueModel, repoInfo?: { owner: string; repo: string }): Promise { + const repo = issue + ? issue.githubRepository + : this._githubRepositories.find( + r => r.remote.owner === repoInfo?.owner && r.remote.repositoryName === repoInfo?.repo, + ); + if (!repo) { + throw new Error(`No matching repository found for getting labels.`); + } + + const { remote, octokit } = await repo.ensure(); + let hasNextPage = false; + let page = 1; + let results: ILabel[] = []; + + do { + const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { + owner: remote.owner, + repo: remote.repositoryName, + per_page: 100, + page, + }); + + results = results.concat( + result.data.map(label => { + return { + name: label.name, + color: label.color, + description: label.description ?? undefined + }; + }), + ); + + results = results.sort((a, b) => a.name.localeCompare(b.name)); + + hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + page += 1; + } while (hasNextPage); + + return results; + } + + async deleteLocalPullRequest(pullRequest: PullRequestModel, force?: boolean): Promise { + if (!pullRequest.localBranchName) { + return; + } + await this.repository.deleteBranch(pullRequest.localBranchName, force); + + let remoteName: string | undefined = undefined; + try { + remoteName = await this.repository.getConfig(`branch.${pullRequest.localBranchName}.remote`); + } catch (e) { } + + if (!remoteName) { + return; + } + + // If the extension created a remote for the branch, remove it if there are no other branches associated with it + const isPRRemote = await PullRequestGitHelper.isRemoteCreatedForPullRequest(this.repository, remoteName); + if (isPRRemote) { + const configs = await this.repository.getConfigs(); + const hasOtherAssociatedBranches = configs.some( + ({ key, value }) => /^branch.*\.remote$/.test(key) && value === remoteName, + ); + + if (!hasOtherAssociatedBranches) { + await this.repository.removeRemote(remoteName); + } + } + + /* __GDPR__ + "branch.delete" : {} + */ + this.telemetry.sendTelemetryEvent('branch.delete'); + } + + // Keep track of how many pages we've fetched for each query, so when we reload we pull the same ones. + private totalFetchedPages = new Map(); + + /** + * This method works in three different ways: + * 1) Initialize: fetch the first page of the first remote that has pages + * 2) Fetch Next: fetch the next page from this remote, or if it has no more pages, the first page from the next remote that does have pages + * 3) Restore: fetch all the pages you previously have fetched + * + * When `options.fetchNextPage === false`, we are in case 2. + * Otherwise: + * If `this.totalFetchQueries[queryId] === 0`, we are in case 1. + * Otherwise, we're in case 3. + */ + private async fetchPagedData( + options: IPullRequestsPagingOptions = { fetchNextPage: false }, + queryId: string, + pagedDataType: PagedDataType = PagedDataType.PullRequest, + type: PRType = PRType.All, + query?: string, + ): Promise> { + const githubRepositoriesWithGitRemotes = pagedDataType === PagedDataType.PullRequest ? this._githubRepositories.filter(repo => this.repository.state.remotes.find(r => r.name === repo.remote.remoteName)) : this._githubRepositories; + if (!githubRepositoriesWithGitRemotes.length) { + return { + items: [], + hasMorePages: false, + hasUnsearchedRepositories: false, + }; + } + + const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; + const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); + + for (const repository of githubRepositoriesWithGitRemotes) { + const remoteId = repository.remote.url.toString() + queryId; + if (!this._repositoryPageInformation.get(remoteId)) { + this._repositoryPageInformation.set(remoteId, { + pullRequestPage: 0, + hasMorePages: null, + }); + } + } + + let pagesFetched = 0; + const itemData: ItemsData = { hasMorePages: false, items: [] }; + const addPage = (page: PullRequestData | undefined) => { + pagesFetched++; + if (page) { + itemData.items = itemData.items.concat(page.items); + itemData.hasMorePages = page.hasMorePages; + } + }; + + const githubRepositories = this._githubRepositories.filter(repo => { + const info = this._repositoryPageInformation.get(repo.remote.url.toString() + queryId); + // If we are in case 1 or 3, don't filter out repos that are out of pages, as we will be querying from the start. + return info && (options.fetchNextPage === false || info.hasMorePages !== false); + }); + + for (let i = 0; i < githubRepositories.length; i++) { + const githubRepository = githubRepositories[i]; + const remoteId = githubRepository.remote.url.toString() + queryId; + let storedPageInfo = this._repositoryPageInformation.get(remoteId); + if (!storedPageInfo) { + Logger.warn(`No page information for ${remoteId}`); + storedPageInfo = { pullRequestPage: 0, hasMorePages: null }; + this._repositoryPageInformation.set(remoteId, storedPageInfo); + } + const pageInformation = storedPageInfo; + + const fetchPage = async ( + pageNumber: number, + ): Promise<{ items: any[]; hasMorePages: boolean } | undefined> => { + // Resolve variables in the query with each repo + const resolvedQuery = query ? await variableSubstitution(query, undefined, + { base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined; + switch (pagedDataType) { + case PagedDataType.PullRequest: { + if (type === PRType.All) { + return githubRepository.getAllPullRequests(pageNumber); + } else { + return githubRepository.getPullRequestsForCategory(resolvedQuery || '', pageNumber); + } + } + case PagedDataType.Milestones: { + return githubRepository.getIssuesForUserByMilestone(pageInformation.pullRequestPage); + } + case PagedDataType.IssuesWithoutMilestone: { + return githubRepository.getIssuesWithoutMilestone(pageInformation.pullRequestPage); + } + case PagedDataType.IssueSearch: { + return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); + } + } + }; + + if (options.fetchNextPage) { + // Case 2. Fetch a single new page, and increment the global number of pages fetched for this query. + pageInformation.pullRequestPage++; + addPage(await fetchPage(pageInformation.pullRequestPage)); + setTotalFetchedPages(getTotalFetchedPages() + 1); + } else { + // Case 1&3. Fetch all the pages we have fetched in the past, or in case 1, just a single page. + + if (pageInformation.pullRequestPage === 0) { + // Case 1. Pretend we have previously fetched the first page, then hand off to the case 3 machinery to "fetch all pages we have fetched in the past" + pageInformation.pullRequestPage = 1; + } + + const pages = await Promise.all( + Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1)), + ); + pages.forEach(page => addPage(page)); + } + + pageInformation.hasMorePages = itemData.hasMorePages; + + // Break early if + // 1) we've received data AND + // 2) either we're fetching just the next page (case 2) + // OR we're fetching all (cases 1&3), and we've fetched as far as we had previously (or further, in case 1). + if ( + itemData.items.length && + (options.fetchNextPage || + ((options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (pagesFetched >= getTotalFetchedPages()))) + ) { + if (getTotalFetchedPages() === 0) { + // We're in case 1, manually set number of pages we looked through until we found first results. + setTotalFetchedPages(pagesFetched); + } + + return { + items: itemData.items, + hasMorePages: pageInformation.hasMorePages, + hasUnsearchedRepositories: i < githubRepositories.length - 1, + }; + } + } + + return { + items: itemData.items, + hasMorePages: false, + hasUnsearchedRepositories: false, + }; + } + + async getPullRequests( + type: PRType, + options: IPullRequestsPagingOptions = { fetchNextPage: false }, + query?: string, + ): Promise> { + const queryId = type.toString() + (query || ''); + return this.fetchPagedData(options, queryId, PagedDataType.PullRequest, type, query); + } + + async getMilestoneIssues( + options: IPullRequestsPagingOptions = { fetchNextPage: false }, + includeIssuesWithoutMilestone: boolean = false, + ): Promise> { + try { + const milestones: ItemsResponseResult = await this.fetchPagedData( + options, + 'milestoneIssuesKey', + PagedDataType.Milestones, + PRType.All + ); + if (includeIssuesWithoutMilestone) { + const additionalIssues: ItemsResponseResult = await this.fetchPagedData( + options, + 'noMilestoneIssuesKey', + PagedDataType.IssuesWithoutMilestone, + PRType.All + ); + milestones.items.push({ + milestone: { + createdAt: new Date(0).toDateString(), + id: '', + title: NO_MILESTONE, + number: -1 + }, + issues: await Promise.all(additionalIssues.items.map(async (issue) => { + const githubRepository = await this.getRepoForIssue(issue); + return new IssueModel(githubRepository, githubRepository.remote, issue); + })), + }); + } + return milestones; + } catch (e) { + Logger.error(`Error fetching milestone issues: ${e instanceof Error ? e.message : e}`, this.id); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; + } + } + + async createMilestone(repository: GitHubRepository, milestoneTitle: string): Promise { + try { + const { data } = await repository.octokit.call(repository.octokit.api.issues.createMilestone, { + owner: repository.remote.owner, + repo: repository.remote.repositoryName, + title: milestoneTitle + }); + return { + title: data.title, + dueOn: data.due_on, + createdAt: data.created_at, + id: data.node_id, + number: data.number + }; + } + catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create a milestone\n{0}', formatError(e))); + return undefined; + } + } + + private async getRepoForIssue(parsedIssue: Issue): Promise { + const remote = new Remote( + parsedIssue.repositoryName!, + parsedIssue.repositoryUrl!, + new Protocol(parsedIssue.repositoryUrl!), + ); + return this.createGitHubRepository(remote, this.credentialStore, true, true); + + } + + /** + * Pull request defaults in the query, like owner and repository variables, will be resolved. + */ + async getIssues( + query?: string, + ): Promise> { + try { + const data = await this.fetchPagedData({ fetchNextPage: false, fetchOnePagePerRepo: false }, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); + const mappedData: ItemsResponseResult = { + items: [], + hasMorePages: data.hasMorePages, + hasUnsearchedRepositories: data.hasUnsearchedRepositories + }; + for (const issue of data.items) { + const githubRepository = await this.getRepoForIssue(issue); + mappedData.items.push(new IssueModel(githubRepository, githubRepository.remote, issue)); + } + return mappedData; + } catch (e) { + Logger.error(`Error fetching issues with query ${query}: ${e instanceof Error ? e.message : e}`, this.id); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; + } + } + + async getMaxIssue(): Promise { + const maxIssues = await Promise.all( + this._githubRepositories.map(repository => { + return repository.getMaxIssue(); + }), + ); + let max: number = 0; + for (const issueNumber of maxIssues) { + if (issueNumber !== undefined) { + max = Math.max(max, issueNumber); + } + } + return max; + } + + async getIssueTemplates(): Promise { + const pattern = '{docs,.github}/ISSUE_TEMPLATE/*.md'; + return vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern), null + ); + } + + async getPullRequestTemplatesWithCache(): Promise { + const cacheLocation = `${CACHED_TEMPLATE_URI}+${this.repository.rootUri.toString()}`; + + const findTemplate = this.getPullRequestTemplates().then((templates) => { + //update cache + if (templates.length > 0) { + this.context.workspaceState.update(cacheLocation, templates[0].toString()); + } else { + this.context.workspaceState.update(cacheLocation, null); + } + return templates; + }); + const hasCachedTemplate = this.context.workspaceState.keys().includes(cacheLocation); + const cachedTemplateLocation = this.context.workspaceState.get(cacheLocation); + if (hasCachedTemplate) { + if (cachedTemplateLocation === null) { + return []; + } else if (cachedTemplateLocation) { + return [vscode.Uri.parse(cachedTemplateLocation)]; + } + } + return findTemplate; + } + + private async getPullRequestTemplates(): Promise { + /** + * Places a PR template can be: + * - At the root, the docs folder, or the.github folder, named pull_request_template.md or PULL_REQUEST_TEMPLATE.md + * - At the same folder locations under a PULL_REQUEST_TEMPLATE folder with any name + */ + const pattern1 = '{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; + const templatesPattern1 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern1) + ); + + const pattern2 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; + const templatesPattern2 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern2), null + ); + + const pattern3 = '{pull_request_template,PULL_REQUEST_TEMPLATE}'; + const templatesPattern3 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern3) + ); + + const pattern4 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}'; + const templatesPattern4 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern4), null + ); + + const pattern5 = 'PULL_REQUEST_TEMPLATE/*.md'; + const templatesPattern5 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern5) + ); + + const pattern6 = '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md'; + const templatesPattern6 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern6), null + ); + + const allResults = await Promise.all([templatesPattern1, templatesPattern2, templatesPattern3, templatesPattern4, templatesPattern5, templatesPattern6]); + + const result = [...allResults[0], ...allResults[1], ...allResults[2], ...allResults[3], ...allResults[4], ...allResults[5]]; + return result; + } + + async getPullRequestDefaults(branch?: Branch): Promise { + if (!branch && !this.repository.state.HEAD) { + throw new DetachedHeadError(this.repository); + } + + const origin = await this.getOrigin(branch); + const meta = await origin.getMetadata(); + const remotesSettingDefault = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(REMOTES)?.defaultValue; + const remotesSettingSetValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + const settingsEqual = (!remotesSettingSetValue || remotesSettingDefault?.every((value, index) => remotesSettingSetValue[index] === value)); + const parent = (meta.fork && meta.parent && settingsEqual) + ? meta.parent + : await (this.findRepo(byRemoteName('upstream')) || origin).getMetadata(); + + return { + owner: parent.owner!.login, + repo: parent.name, + base: getOverrideBranch() ?? parent.default_branch, + }; + } + + async getPullRequestDefaultRepo(): Promise { + const defaults = await this.getPullRequestDefaults(); + return this.findRepo(repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo) || this._githubRepositories[0]; + } + + async getMetadata(remote: string): Promise { + const repo = this.findRepo(byRemoteName(remote)); + return repo && repo.getMetadata(); + } + + async getHeadCommitMessage(): Promise { + const { repository } = this; + if (repository.state.HEAD && repository.state.HEAD.commit) { + const { message } = await repository.getCommit(repository.state.HEAD.commit); + return message; + } + + return ''; + } + + async getTipCommitMessage(branch: string): Promise { + Logger.debug(`Git tip message for branch ${branch} - enter`, this.id); + const { repository } = this; + let { commit } = await repository.getBranch(branch); + let message: string = ''; + let count = 0; + do { + if (commit) { + let fullCommit: Commit = await repository.getCommit(commit); + if (fullCommit.parents.length <= 1) { + message = fullCommit.message; + break; + } else { + commit = fullCommit.parents[0]; + } + } + count++; + } while (message === '' && commit && count < 5); + + + Logger.debug(`Git tip message for branch ${branch} - done`, this.id); + return message; + } + + async getOrigin(branch?: Branch): Promise { + if (!this._githubRepositories.length) { + throw new NoGitHubReposError(this.repository); + } + + const upstreamRef = branch ? branch.upstream : this.upstreamRef; + if (upstreamRef) { + // If our current branch has an upstream ref set, find its GitHubRepository. + const upstream = this.findRepo(byRemoteName(upstreamRef.remote)); + + // If the upstream wasn't listed in the remotes setting, create a GitHubRepository + // object for it if is does point to GitHub. + if (!upstream) { + const remote = (await this.getAllGitHubRemotes()).find(r => r.remoteName === upstreamRef.remote); + if (remote) { + return this.createAndAddGitHubRepository(remote, this._credentialStore); + } + + Logger.error(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); + + // No GitHubRepository? We currently won't try pushing elsewhere, + // so fail. + throw new BadUpstreamError(this.repository.state.HEAD!.name!, upstreamRef, 'is not a GitHub repo'); + } + + // Otherwise, we'll push upstream. + return upstream; + } + + // If no upstream is set, let's go digging. + const [first, ...rest] = this._githubRepositories; + return !rest.length // Is there only one GitHub remote? + ? first // I GUESS THAT'S WHAT WE'RE GOING WITH, THEN. + : // Otherwise, let's try... + this.findRepo(byRemoteName('origin')) || // by convention + this.findRepo(ownedByMe) || // bc maybe we can push there + first; // out of raw desperation + } + + findRepo(where: Predicate): GitHubRepository | undefined { + return this._githubRepositories.filter(where)[0]; + } + + get upstreamRef(): UpstreamRef | undefined { + const { HEAD } = this.repository.state; + return HEAD && HEAD.upstream; + } + + async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { + const repo = this._githubRepositories.find( + r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, + ); + if (!repo) { + throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); + } + + let pullRequestModel: PullRequestModel | undefined; + try { + pullRequestModel = await repo.createPullRequest(params); + + const branchNameSeparatorIndex = params.head.indexOf(':'); + const branchName = params.head.slice(branchNameSeparatorIndex + 1); + await PullRequestGitHelper.associateBranchWithPullRequest(this._repository, pullRequestModel, branchName); + + /* __GDPR__ + "pr.create.success" : { + "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); + return pullRequestModel; + } catch (e) { + if (e.message.indexOf('No commits between ') > -1) { + // There are unpushed commits + if (this._repository.state.HEAD?.ahead) { + // Offer to push changes + const pushCommits = vscode.l10n.t({ message: 'Push Commits', comment: 'Pushes the local commits to the remote.' }); + const shouldPush = await vscode.window.showInformationMessage( + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to push your local commits and create the pull request?', params.base, params.head), + { modal: true }, + pushCommits, + ); + if (shouldPush === pushCommits) { + await this._repository.push(); + return this.createPullRequest(params); + } else { + return; + } + } + + // There are uncommitted changes + if (this._repository.state.workingTreeChanges.length || this._repository.state.indexChanges.length) { + const commitChanges = vscode.l10n.t('Commit Changes'); + const shouldCommit = await vscode.window.showInformationMessage( + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to commit your changes and create the pull request?', params.base, params.head), + { modal: true }, + commitChanges, + ); + if (shouldCommit === commitChanges) { + await this._repository.add(this._repository.state.indexChanges.map(change => change.uri.fsPath)); + await this.repository.commit(`${params.title}${params.body ? `\n${params.body}` : ''}`); + await this._repository.push(); + return this.createPullRequest(params); + } else { + return; + } + } + } + + Logger.error(`Creating pull requests failed: ${e}`, this.id); + + /* __GDPR__ + "pr.create.failure" : { + "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.create.failure', { + isDraft: (params.draft || '').toString(), + }); + + if (pullRequestModel) { + // We have created the pull request but something else failed (ex., modifying the git config) + // We shouldn't show an error as the pull request was successfully created + return pullRequestModel; + } + throw new Error(formatError(e)); + } + } + + async createIssue(params: OctokitCommon.IssuesCreateParams): Promise { + try { + const repo = this._githubRepositories.find( + r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, + ); + if (!repo) { + throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); + } + + await repo.ensure(); + + // Create PR + const { data } = await repo.octokit.call(repo.octokit.api.issues.create, params); + const item = convertRESTIssueToRawPullRequest(data, repo); + const issueModel = new IssueModel(repo, repo.remote, item); + + /* __GDPR__ + "issue.create.success" : { + } + */ + this.telemetry.sendTelemetryEvent('issue.create.success'); + return issueModel; + } catch (e) { + Logger.error(` Creating issue failed: ${e}`, this.id); + + /* __GDPR__ + "issue.create.failure" : {} + */ + this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); + } + + return undefined; + } + + async assignIssue(issue: IssueModel, login: string): Promise { + try { + const repo = this._githubRepositories.find( + r => r.remote.owner === issue.remote.owner && r.remote.repositoryName === issue.remote.repositoryName, + ); + if (!repo) { + throw new Error( + `No matching repository ${issue.remote.repositoryName} found for ${issue.remote.owner}`, + ); + } + + await repo.ensure(); + + const param: OctokitCommon.IssuesAssignParams = { + assignees: [login], + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + issue_number: issue.number, + }; + await repo.octokit.call(repo.octokit.api.issues.addAssignees, param); + + /* __GDPR__ + "issue.assign.success" : { + } + */ + this.telemetry.sendTelemetryEvent('issue.assign.success'); + } catch (e) { + Logger.error(`Assigning issue failed: ${e}`, this.id); + + /* __GDPR__ + "issue.assign.failure" : { + } + */ + this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); + } + } + + getCurrentUser(githubRepository?: GitHubRepository): Promise { + if (!githubRepository) { + githubRepository = this.gitHubRepositories[0]; + } + return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); + } + + async mergePullRequest( + pullRequest: PullRequestModel, + title?: string, + description?: string, + method?: 'merge' | 'squash' | 'rebase', + ): Promise<{ merged: boolean, message: string }> { + const { octokit, remote } = await pullRequest.githubRepository.ensure(); + + const activePRSHA = this.activePullRequest && this.activePullRequest.head && this.activePullRequest.head.sha; + const workingDirectorySHA = this.repository.state.HEAD && this.repository.state.HEAD.commit; + const mergingPRSHA = pullRequest.head && pullRequest.head.sha; + const workingDirectoryIsDirty = this.repository.state.workingTreeChanges.length > 0; + + if (activePRSHA === mergingPRSHA) { + // We're on the branch of the pr being merged. + + if (workingDirectorySHA !== mergingPRSHA) { + // We are looking at different commit than what will be merged + const { ahead } = this.repository.state.HEAD!; + const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this PR branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); + const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this PR branch.\n\nWould you like to proceed anyway?'); + if (ahead && + (await vscode.window.showWarningMessage( + ahead > 1 ? pluralMessage : singularMessage, + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined) { + + return { + merged: false, + message: 'unpushed changes', + }; + } + } + + if (workingDirectoryIsDirty) { + // We have made changes to the PR that are not committed + if ( + (await vscode.window.showWarningMessage( + vscode.l10n.t('You have uncommitted changes on this PR branch.\n\n Would you like to proceed anyway?'), + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined + ) { + return { + merged: false, + message: 'uncommitted changes', + }; + } + } + } + + return octokit.call(octokit.api.pulls.merge, { + commit_message: description, + commit_title: title, + merge_method: + method || + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'merge' | 'squash' | 'rebase'>(DEFAULT_MERGE_METHOD), + owner: remote.owner, + repo: remote.repositoryName, + pull_number: pullRequest.number, + }) + .then(x => { + /* __GDPR__ + "pr.merge.success" : {} + */ + this.telemetry.sendTelemetryEvent('pr.merge.success'); + this._onDidMergePullRequest.fire(); + return x.data; + }) + .catch(e => { + /* __GDPR__ + "pr.merge.failure" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.merge.failure'); + throw e; + }); + } + + async deleteBranch(pullRequest: PullRequestModel) { + await pullRequest.githubRepository.deleteBranch(pullRequest); + } + + private async getBranchDeletionItems() { + const allConfigs = await this.repository.getConfigs(); + const branchInfos: Map = new Map(); + + allConfigs.forEach(config => { + const key = config.key; + const matches = /^branch\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const branchName = matches[1]; + + if (!branchInfos.has(branchName)) { + branchInfos.set(branchName, {}); + } + + const value = branchInfos.get(branchName); + if (matches[2] === 'remote') { + value!['remote'] = config.value; + } + + if (matches[2] === 'github-pr-owner-number') { + const metadata = PullRequestGitHelper.parsePullRequestMetadata(config.value); + value!['metadata'] = metadata; + } + + branchInfos.set(branchName, value!); + } + }); + + const actions: (vscode.QuickPickItem & { metadata: PullRequestMetadata; legacy?: boolean })[] = []; + branchInfos.forEach((value, key) => { + if (value.metadata) { + const activePRUrl = this.activePullRequest && this.activePullRequest.base.repositoryCloneUrl; + const matchesActiveBranch = activePRUrl + ? activePRUrl.owner === value.metadata.owner && + activePRUrl.repositoryName === value.metadata.repositoryName && + this.activePullRequest && + this.activePullRequest.number === value.metadata.prNumber + : false; + + if (!matchesActiveBranch) { + actions.push({ + label: `${key}`, + description: `${value.metadata!.repositoryName}/${value.metadata!.owner} #${value.metadata.prNumber + }`, + picked: false, + metadata: value.metadata!, + }); + } + } + }); + + const results = await Promise.all( + actions.map(async action => { + const metadata = action.metadata; + const githubRepo = this._githubRepositories.find( + repo => + repo.remote.owner.toLowerCase() === metadata!.owner.toLowerCase() && + repo.remote.repositoryName.toLowerCase() === metadata!.repositoryName.toLowerCase(), + ); + + if (!githubRepo) { + return action; + } + + const { remote, query, schema } = await githubRepo.ensure(); + try { + const { data } = await query({ + query: schema.PullRequestState, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: metadata!.prNumber, + }, + }); + + action.legacy = data.repository?.pullRequest.state !== 'OPEN'; + } catch { } + + return action; + }), + ); + + results.forEach(result => { + if (result.legacy) { + result.picked = true; + } else { + result.description = vscode.l10n.t('{0} is still Open', result.description!); + } + }); + + return results; + } + + public async cleanupAfterPullRequest(branchName: string, pullRequest: PullRequestModel) { + const defaults = await this.getPullRequestDefaults(); + if (branchName === defaults.base) { + Logger.debug('Not cleaning up default branch.', this.id); + return; + } + if (pullRequest.author.login === (await this.getCurrentUser()).login) { + Logger.debug('Not cleaning up user\'s branch.', this.id); + return; + } + const branch = await this.repository.getBranch(branchName); + const remote = branch.upstream?.remote; + try { + Logger.debug(`Cleaning up branch ${branchName}`, this.id); + await this.repository.deleteBranch(branchName); + } catch (e) { + // The branch probably had unpushed changes and cannot be deleted. + return; + } + if (!remote) { + return; + } + const remotes = await this.getDeleatableRemotes(undefined); + if (remotes.has(remote) && remotes.get(remote)!.createdForPullRequest) { + Logger.debug(`Cleaning up remote ${remote}`, this.id); + this.repository.removeRemote(remote); + } + } + + private async getDeleatableRemotes(nonExistantBranches?: Set) { + const newConfigs = await this.repository.getConfigs(); + const remoteInfos: Map< + string, + { branches: Set; url?: string; createdForPullRequest?: boolean } + > = new Map(); + + newConfigs.forEach(config => { + const key = config.key; + let matches = /^branch\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const branchName = matches[1]; + + if (matches[2] === 'remote') { + const remoteName = config.value; + + if (!remoteInfos.has(remoteName)) { + remoteInfos.set(remoteName, { branches: new Set() }); + } + + if (!nonExistantBranches?.has(branchName)) { + const value = remoteInfos.get(remoteName); + value!.branches.add(branchName); + } + } + } + + matches = /^remote\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const remoteName = matches[1]; + + if (!remoteInfos.has(remoteName)) { + remoteInfos.set(remoteName, { branches: new Set() }); + } + + const value = remoteInfos.get(remoteName); + + if (matches[2] === 'github-pr-remote') { + value!.createdForPullRequest = config.value === 'true'; + } + + if (matches[2] === 'url') { + value!.url = config.value; + } + } + }); + return remoteInfos; + } + + private async getRemoteDeletionItems(nonExistantBranches: Set) { + // check if there are remotes that should be cleaned + const remoteInfos = await this.getDeleatableRemotes(nonExistantBranches); + const remoteItems: (vscode.QuickPickItem & { remote: string })[] = []; + + remoteInfos.forEach((value, key) => { + if (value.branches.size === 0) { + let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); + if (value.url) { + description = description ? `${description} ${value.url}` : value.url; + } + + remoteItems.push({ + label: key, + description: description, + picked: value.createdForPullRequest, + remote: key, + }); + } + }); + + return remoteItems; + } + + async deleteLocalBranchesNRemotes() { + return new Promise(async resolve => { + const quickPick = vscode.window.createQuickPick(); + quickPick.canSelectMany = true; + quickPick.ignoreFocusOut = true; + quickPick.placeholder = vscode.l10n.t('Choose local branches you want to delete permanently'); + quickPick.show(); + quickPick.busy = true; + + // Check local branches + const results = await this.getBranchDeletionItems(); + const defaults = await this.getPullRequestDefaults(); + quickPick.items = results; + quickPick.selectedItems = results.filter(result => { + // Do not pick the default branch for the repo. + return result.picked && !((result.label === defaults.base) && (result.metadata.owner === defaults.owner) && (result.metadata.repositoryName === defaults.repo)); + }); + quickPick.busy = false; + + let firstStep = true; + quickPick.onDidAccept(async () => { + quickPick.busy = true; + + if (firstStep) { + const picks = quickPick.selectedItems; + const nonExistantBranches = new Set(); + if (picks.length) { + try { + await Promise.all( + picks.map(async pick => { + try { + await this.repository.deleteBranch(pick.label, true); + } catch (e) { + if ((typeof e.stderr === 'string') && (e.stderr as string).includes('not found')) { + // TODO: The git extension API doesn't support removing configs + // If that support is added we should remove the config as it is no longer useful. + nonExistantBranches.add(pick.label); + } else { + throw e; + } + } + })); + } catch (e) { + quickPick.hide(); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); + } + } + + firstStep = false; + const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); + + if (remoteItems && remoteItems.length) { + quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); + quickPick.items = remoteItems; + quickPick.selectedItems = remoteItems.filter(item => item.picked); + } else { + quickPick.hide(); + } + } else { + // delete remotes + const picks = quickPick.selectedItems; + if (picks.length) { + await Promise.all( + picks.map(async pick => { + await this.repository.removeRemote(pick.label); + }), + ); + } + quickPick.hide(); + } + quickPick.busy = false; + }); + + quickPick.onDidHide(() => { + resolve(); + }); + }); + } + + async getPullRequestRepositoryDefaultBranch(issue: IssueModel): Promise { + const branch = await issue.githubRepository.getDefaultBranch(); + return branch; + } + + async getPullRequestRepositoryAccessAndMergeMethods( + pullRequest: PullRequestModel, + ): Promise { + const mergeOptions = await pullRequest.githubRepository.getRepoAccessAndMergeMethods(); + return mergeOptions; + } + + async mergeQueueMethodForBranch(branch: string, owner: string, repoName: string): Promise { + return (await this.gitHubRepositories.find(repository => repository.remote.owner === owner && repository.remote.repositoryName === repoName)?.mergeQueueMethodForBranch(branch)); + } + + async fulfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { + try { + if (!pullRequest.isResolved()) { + return; + } + + Logger.debug(`Fulfill pull request missing info - start`, this.id); + const githubRepository = pullRequest.githubRepository; + const { octokit, remote } = await githubRepository.ensure(); + + if (!pullRequest.base) { + const { data } = await octokit.call(octokit.api.pulls.get, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: pullRequest.number, + }); + pullRequest.update(convertRESTPullRequestToRawPullRequest(data, githubRepository)); + } + + if (!pullRequest.mergeBase) { + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, + head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, + }); + + pullRequest.mergeBase = data.merge_base_commit.sha; + } + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); + } + Logger.debug(`Fulfill pull request missing info - done`, this.id); + } + + //#region Git related APIs + + private async resolveItem(owner: string, repositoryName: string): Promise { + let githubRepo = this._githubRepositories.find(repo => { + const ret = + repo.remote.owner.toLowerCase() === owner.toLowerCase() && + repo.remote.repositoryName.toLowerCase() === repositoryName.toLowerCase(); + return ret; + }); + + if (!githubRepo) { + Logger.appendLine(`GitHubRepository not found: ${owner}/${repositoryName}`, this.id); + // try to create the repository + githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + } + return githubRepo; + } + + async resolvePullRequest( + owner: string, + repositoryName: string, + pullRequestNumber: number, + ): Promise { + const githubRepo = await this.resolveItem(owner, repositoryName); + Logger.appendLine(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); + if (githubRepo) { + const pr = await githubRepo.getPullRequest(pullRequestNumber); + Logger.appendLine(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); + if (pr) { + if (await githubRepo.hasBranch(pr.base.name)) { + return pr; + } + } + } + return undefined; + } + + async resolveIssue( + owner: string, + repositoryName: string, + pullRequestNumber: number, + withComments: boolean = false, + ): Promise { + const githubRepo = await this.resolveItem(owner, repositoryName); + if (githubRepo) { + return githubRepo.getIssue(pullRequestNumber, withComments); + } + return undefined; + } + + async resolveUser(owner: string, repositoryName: string, login: string): Promise { + Logger.debug(`Fetch user ${login}`, this.id); + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + const { query, schema } = await githubRepository.ensure(); + + try { + const { data } = await query({ + query: schema.GetUser, + variables: { + login, + }, + }); + return parseGraphQLUser(data, githubRepository); + } catch (e) { + // Ignore cases where the user doesn't exist + if (!(e.message as (string | undefined))?.startsWith('GraphQL error: Could not resolve to a User with the login of')) { + Logger.warn(e.message); + } + } + return undefined; + } + + async getMatchingPullRequestMetadataForBranch() { + if (!this.repository || !this.repository.state.HEAD || !this.repository.state.HEAD.name) { + return null; + } + + const matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + this.repository.state.HEAD.name, + ); + return matchingPullRequestMetadata; + } + + async getMatchingPullRequestMetadataFromGitHub(branch: Branch, remoteName?: string, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + try { + if (remoteName) { + return this.getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName, upstreamBranchName); + } + return this.getMatchingPullRequestMetadataFromGitHubWithUrl(branch, remoteUrl, upstreamBranchName); + } catch (e) { + Logger.error(`Unable to get matching pull request metadata from GitHub: ${e}`, this.id); + return null; + } + } + + async getMatchingPullRequestMetadataFromGitHubWithUrl(branch: Branch, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!remoteUrl) { + return null; + } + let headGitHubRepo = this.gitHubRepositories.find(repo => repo.remote.url.toLowerCase() === remoteUrl.toLowerCase()); + let protocol: Protocol | undefined; + if (!headGitHubRepo && this.gitHubRepositories.length > 0) { + protocol = new Protocol(remoteUrl); + const remote = parseRemote(protocol.repositoryName, remoteUrl, protocol); + if (remote) { + headGitHubRepo = await this.createGitHubRepository(remote, this.credentialStore, true, true); + } + } + const matchingPR = await this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + if (matchingPR && (branch.upstream === undefined) && protocol && headGitHubRepo && branch.name) { + const newRemote = await PullRequestGitHelper.createRemote(this.repository, headGitHubRepo?.remote, protocol); + const trackedBranchName = `refs/remotes/${newRemote}/${matchingPR.model.head?.name}`; + await this.repository.fetch({ remote: newRemote, ref: matchingPR.model.head?.name }); + await this.repository.setBranchUpstream(branch.name, trackedBranchName); + } + + return matchingPR; + } + + async getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!remoteName) { + return null; + } + + const headGitHubRepo = this.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + return this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + } + + private async doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo?: GitHubRepository, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!headGitHubRepo || !upstreamBranchName) { + return null; + } + + const headRepoMetadata = await headGitHubRepo?.getMetadata(); + if (!headRepoMetadata?.owner) { + return null; + } + + const parentRepos = this.gitHubRepositories.filter(repo => { + if (headRepoMetadata.fork) { + return repo.remote.owner === headRepoMetadata.parent?.owner?.login && repo.remote.repositoryName === headRepoMetadata.parent.name; + } else { + return repo.remote.owner === headRepoMetadata.owner?.login && repo.remote.repositoryName === headRepoMetadata.name; + } + }); + + // Search through each github repo to see if it has a PR with this head branch. + for (const repo of parentRepos) { + const matchingPullRequest = await repo.getPullRequestForBranch(upstreamBranchName, headRepoMetadata.owner.login); + if (matchingPullRequest) { + return { + owner: repo.remote.owner, + repositoryName: repo.remote.repositoryName, + prNumber: matchingPullRequest.number, + model: matchingPullRequest, + }; + } + } + return null; + } + + async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest, progress); + } + + async getBranchNameForPullRequest(pullRequest: PullRequestModel) { + return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, pullRequest); + } + + async fetchAndCheckout(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest, progress); + } + + async checkout(branchName: string): Promise { + return this.repository.checkout(branchName); + } + + async fetchById(githubRepo: GitHubRepository, id: number): Promise { + const pullRequest = await githubRepo.getPullRequest(id); + if (pullRequest) { + return pullRequest; + } else { + vscode.window.showErrorMessage(vscode.l10n.t('Pull request number {0} does not exist in {1}', id, `${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`), { modal: true }); + } + } + + public async checkoutDefaultBranch(branch: string): Promise { + let branchObj: Branch | undefined; + try { + branchObj = await this.repository.getBranch(branch); + + const currentBranch = this.repository.state.HEAD?.name; + if (currentBranch === branchObj.name) { + const chooseABranch = vscode.l10n.t('Choose a Branch'); + vscode.window.showInformationMessage(vscode.l10n.t('The default branch is already checked out.'), chooseABranch).then(choice => { + if (choice === chooseABranch) { + return git.checkout(); + } + }); + return; + } + + // respect the git setting to fetch before checkout + if (vscode.workspace.getConfiguration(GIT).get(PULL_BEFORE_CHECKOUT, false) && branchObj.upstream) { + await this.repository.fetch({ remote: branchObj.upstream.remote, ref: `${branchObj.upstream.name}:${branchObj.name}` }); + } + + if (branchObj.upstream && branch === branchObj.upstream.name) { + await this.repository.checkout(branch); + } else { + await git.checkout(); + } + + const fileClose: Thenable[] = []; + // Close the PR description and any open review scheme files. + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + let uri: vscode.Uri | string | undefined; + if (tab.input instanceof vscode.TabInputText) { + uri = tab.input.uri; + } else if (tab.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.original; + } else if (tab.input instanceof vscode.TabInputWebview) { + uri = tab.input.viewType; + } + if ((uri instanceof vscode.Uri && uri.scheme === Schemes.Review) || (typeof uri === 'string' && uri.endsWith(PULL_REQUEST_OVERVIEW_VIEW_TYPE))) { + fileClose.push(vscode.window.tabGroups.close(tab)); + } + } + } + await Promise.all(fileClose); + } catch (e) { + if (e.gitErrorCode) { + // for known git errors, we should provide actions for users to continue. + if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + vscode.window.showErrorMessage( + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), + ); + return; + } + } + Logger.error(`Exiting failed: ${e}. Target branch ${branch} used to find branch ${branchObj?.name ?? 'unknown'} with upstream ${branchObj?.upstream?.name ?? 'unknown'}.`); + vscode.window.showErrorMessage(`Exiting failed: ${e}`); + } + } + + private async pullBranchConfiguration(): Promise<'never' | 'prompt' | 'always'> { + const neverShowPullNotification = this.context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); + if (neverShowPullNotification) { + this.context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, false); + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt'); + } + + private async pullBranch(branch: Branch) { + if (this._repository.state.HEAD?.name === branch.name) { + await this._repository.pull(); + } + } + + private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { + if (!this._updateMessageShown || autoStashSetting) { + this._updateMessageShown = true; + const pull = vscode.l10n.t('Pull'); + const always = vscode.l10n.t('Always Pull'); + const never = vscode.l10n.t('Never Show Again'); + const options = [pull]; + if (!autoStashSetting) { + options.push(always, never); + } + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('There are updates available for pull request {0}.', `${pr.number}: ${pr.title}`), + {}, + ...options + ); + + if (result === pull) { + await this.pullBranch(branch); + this._updateMessageShown = false; + } else if (never) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } else if (always) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'always', vscode.ConfigurationTarget.Global); + await this.pullBranch(branch); + } + } + } + + private _updateMessageShown: boolean = false; + public async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel, shouldFetch: boolean): Promise { + if (this.activePullRequest?.id !== pr.id) { + return; + } + const branch = this._repository.state.HEAD; + if (branch) { + const remote = branch.upstream ? branch.upstream.remote : null; + const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; + if (remote) { + try { + if (shouldFetch && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ALLOW_FETCH, true)) { + await this._repository.fetch(remote, remoteBranch); + } + } catch (e) { + if (e.stderr) { + if ((e.stderr as string).startsWith('fatal: couldn\'t find remote ref')) { + // We've managed to check out the PR, but the remote has been deleted. This is fine, but we can't fetch now. + } else { + vscode.window.showErrorMessage(vscode.l10n.t('An error occurred when fetching the repository: {0}', e.stderr)); + } + } + Logger.error(`Error when fetching: ${e.stderr ?? e}`, this.id); + } + const pullBranchConfiguration = await this.pullBranchConfiguration(); + if (branch.behind !== undefined && branch.behind > 0) { + switch (pullBranchConfiguration) { + case 'always': { + const autoStash = vscode.workspace.getConfiguration(GIT).get(AUTO_STASH, false); + if (autoStash) { + return this.promptPullBrach(pr, branch, autoStash); + } else { + return this.pullBranch(branch); + } + } + case 'prompt': { + return this.promptPullBrach(pr, branch); + } + case 'never': return; + } + } + + } + } + } + + private findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { + return this._githubRepositories.find( + r => + (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) + && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) + && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), + ); + } + + private async createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean) { + const repo = new GitHubRepository(GitHubRemote.remoteAsGitHub(remote, await this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), this.repository.rootUri, credentialStore, this.telemetry, silent); + this._githubRepositories.push(repo); + return repo; + } + + private _createGitHubRepositoryBulkhead = bulkhead(1, 300); + async createGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean, ignoreRemoteName: boolean = false): Promise { + // Use a bulkhead/semaphore to ensure that we don't create multiple GitHubRepositories for the same remote at the same time. + return this._createGitHubRepositoryBulkhead.execute(() => { + return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName }) ?? + this.createAndAddGitHubRepository(remote, credentialStore, silent); + }); + } + + async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { + const existing = this.findExistingGitHubRepository({ owner, repositoryName }); + if (existing) { + return existing; + } + const gitRemotes = parseRepositoryRemotes(this.repository); + const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); + const uri = gitRemote?.url ?? `https://github.com/${owner}/${repositoryName}`; + return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); + } + + async findUpstreamForItem(item: { + remote: Remote; + githubRepository: GitHubRepository; + }): Promise<{ needsFork: boolean; upstream?: GitHubRepository; remote?: Remote }> { + let upstream: GitHubRepository | undefined; + let existingForkRemote: Remote | undefined; + for (const githubRepo of this.gitHubRepositories) { + if ( + !upstream && + githubRepo.remote.owner === item.remote.owner && + githubRepo.remote.repositoryName === item.remote.repositoryName + ) { + upstream = githubRepo; + continue; + } + const forkDetails = await githubRepo.getRepositoryForkDetails(); + if ( + forkDetails && + forkDetails.isFork && + forkDetails.parent.owner.login === item.remote.owner && + forkDetails.parent.name === item.remote.repositoryName + ) { + const foundforkPermission = await githubRepo.getViewerPermission(); + if ( + foundforkPermission === ViewerPermission.Admin || + foundforkPermission === ViewerPermission.Maintain || + foundforkPermission === ViewerPermission.Write + ) { + existingForkRemote = githubRepo.remote; + break; + } + } + } + let needsFork = false; + if (upstream && !existingForkRemote) { + const permission = await item.githubRepository.getViewerPermission(); + if ( + permission === ViewerPermission.Read || + permission === ViewerPermission.Triage || + permission === ViewerPermission.Unknown + ) { + needsFork = true; + } + } + return { needsFork, upstream, remote: existingForkRemote }; + } + + async forkWithProgress( + progress: vscode.Progress<{ message?: string; increment?: number }>, + githubRepository: GitHubRepository, + repoString: string, + matchingRepo: Repository, + ): Promise { + progress.report({ message: vscode.l10n.t('Forking {0}...', repoString) }); + const result = await githubRepository.fork(); + progress.report({ increment: 50 }); + if (!result) { + vscode.window.showErrorMessage( + vscode.l10n.t('Unable to create a fork of {0}. Check that your GitHub credentials are correct.', repoString), + ); + return; + } + + const workingRemoteName: string = + matchingRepo.state.remotes.length > 1 ? 'origin' : matchingRepo.state.remotes[0].name; + progress.report({ message: vscode.l10n.t('Adding remotes. This may take a few moments.') }); + await matchingRepo.renameRemote(workingRemoteName, 'upstream'); + await matchingRepo.addRemote(workingRemoteName, result); + // Now the extension is responding to all the git changes. + await new Promise(resolve => { + if (this.gitHubRepositories.length === 0) { + const disposable = this.onDidChangeRepositories(() => { + if (this.gitHubRepositories.length > 0) { + disposable.dispose(); + resolve(); + } + }); + } else { + resolve(); + } + }); + progress.report({ increment: 50 }); + return workingRemoteName; + } + + async doFork( + githubRepository: GitHubRepository, + repoString: string, + matchingRepo: Repository, + ): Promise { + return vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating Fork') }, + async progress => { + try { + return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); + } catch (e) { + vscode.window.showErrorMessage(`Creating fork failed: ${e}`); + } + return undefined; + }, + ); + } + + async tryOfferToFork(githubRepository: GitHubRepository): Promise { + const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; + + const fork = vscode.l10n.t('Fork'); + const dontFork = vscode.l10n.t('Don\'t Fork'); + const response = await vscode.window.showInformationMessage( + vscode.l10n.t('You don\'t have permission to push to {0}. Do you want to fork {0}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to {0}.', repoString), + { modal: true }, + fork, + dontFork, + ); + switch (response) { + case fork: { + return this.doFork(githubRepository, repoString, this.repository); + } + case dontFork: + return false; + default: + return undefined; + } + } + + public getTitleAndDescriptionProvider(searchTerm?: string) { + return this._git.getTitleAndDescriptionProvider(searchTerm); + } + + dispose() { + this._subs.forEach(sub => sub.dispose()); + this._onDidDispose.fire(); + } +} + +export function getEventType(text: string) { + switch (text) { + case 'committed': + return EventType.Committed; + case 'mentioned': + return EventType.Mentioned; + case 'subscribed': + return EventType.Subscribed; + case 'commented': + return EventType.Commented; + case 'reviewed': + return EventType.Reviewed; + default: + return EventType.Other; + } +} + +const ownedByMe: Predicate = repo => { + const { currentUser = null } = repo.octokit as any; + return currentUser && repo.remote.owner === currentUser.login; +}; + +export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => + remoteName === name; + +export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { + const message = await promise; + if (!message) { + return; + } + const idxLineBreak = message.indexOf('\n'); + return { + title: idxLineBreak === -1 ? message : message.substr(0, idxLineBreak), + + body: idxLineBreak === -1 ? '' : message.slice(idxLineBreak + 1).trim(), + }; +}; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index a5d56dedcd..8e64ac3871 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -1,1512 +1,1513 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; -import * as vscode from 'vscode'; -import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; -import Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { GitHubRemote, parseRemote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; -import { mergeQuerySchemaWithShared, OctokitCommon, Schema } from './common'; -import { CredentialStore, GitHub } from './credentials'; -import { - AssignableUsersResponse, - CreatePullRequestResponse, - FileContentResponse, - ForkDetailsResponse, - GetBranchResponse, - GetChecksResponse, - isCheckRun, - IssuesResponse, - IssuesSearchResponse, - ListBranchesResponse, - MaxIssueResponse, - MentionableUsersResponse, - MergeQueueForBranchResponse, - MilestoneIssuesResponse, - OrganizationTeamsCountResponse, - OrganizationTeamsResponse, - OrgProjectsResponse, - PullRequestParticipantsResponse, - PullRequestResponse, - PullRequestsResponse, - RepoProjectsResponse, - ViewerPermissionResponse, -} from './graphql'; -import { - CheckState, - IAccount, - IMilestone, - IProject, - Issue, - ITeam, - MergeMethod, - PullRequest, - PullRequestChecks, - PullRequestReviewRequirement, - RepoAccessAndMergeMethods, -} from './interface'; -import { IssueModel } from './issueModel'; -import { LoggingOctokit } from './loggingOctokit'; -import { PullRequestModel } from './pullRequestModel'; -import defaultSchema from './queries.gql'; -import * as extraSchema from './queriesExtra.gql'; -import * as limitedSchema from './queriesLimited.gql'; -import * as sharedSchema from './queriesShared.gql'; -import { - convertRESTPullRequestToRawPullRequest, - getAvatarWithEnterpriseFallback, - getOverrideBranch, - getPRFetchQuery, - isInCodespaces, - parseGraphQLIssue, - parseGraphQLPullRequest, - parseGraphQLViewerPermission, - parseMergeMethod, - parseMilestone, -} from './utils'; - -export const PULL_REQUEST_PAGE_SIZE = 20; - -const GRAPHQL_COMPONENT_ID = 'GraphQL'; - -export interface ItemsData { - items: any[]; - hasMorePages: boolean; -} - -export interface IssueData extends ItemsData { - items: Issue[]; - hasMorePages: boolean; -} - -export interface PullRequestData extends ItemsData { - items: PullRequestModel[]; -} - -export interface MilestoneData extends ItemsData { - items: { milestone: IMilestone; issues: IssueModel[] }[]; - hasMorePages: boolean; -} - -export enum ViewerPermission { - Unknown = 'unknown', - Admin = 'ADMIN', - Maintain = 'MAINTAIN', - Read = 'READ', - Triage = 'TRIAGE', - Write = 'WRITE', -} - -export enum TeamReviewerRefreshKind { - None, - Try, - Force -} - -export interface ForkDetails { - isFork: boolean; - parent: { - owner: { - login: string; - }; - name: string; - }; -} - -export interface IMetadata extends OctokitCommon.ReposGetResponseData { - currentUser: any; -} - -interface GraphQLError { - extensions: { - code: string; - }; -} - -export class GitHubRepository implements vscode.Disposable { - static ID = 'GitHubRepository'; - protected _initialized: boolean = false; - protected _hub: GitHub | undefined; - protected _metadata: IMetadata | undefined; - private _toDispose: vscode.Disposable[] = []; - public commentsController?: vscode.CommentController; - public commentsHandler?: PRCommentControllerRegistry; - private _pullRequestModels = new Map(); - private _queriesSchema: any; - private _areQueriesLimited: boolean = false; - - private _onDidAddPullRequest: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; - - public get hub(): GitHub { - if (!this._hub) { - if (!this._initialized) { - throw new Error('Call ensure() before accessing this property.'); - } else { - throw new AuthenticationError('Not authenticated.'); - } - } - return this._hub; - } - - public equals(repo: GitHubRepository): boolean { - return this.remote.equals(repo.remote); - } - - get pullRequestModels(): Map { - return this._pullRequestModels; - } - - public async ensureCommentsController(): Promise { - try { - if (this.commentsController) { - return; - } - - await this.ensure(); - this.commentsController = vscode.comments.createCommentController( - `github-browse-${this.remote.normalizedHost}-${this.remote.owner}-${this.remote.repositoryName}`, - `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, - ); - this.commentsHandler = new PRCommentControllerRegistry(this.commentsController); - this._toDispose.push(this.commentsHandler); - this._toDispose.push(this.commentsController); - } catch (e) { - console.log(e); - } - } - - dispose() { - this._toDispose.forEach(d => d.dispose()); - this._toDispose = []; - this.commentsController = undefined; - this.commentsHandler = undefined; - } - - public get octokit(): LoggingOctokit { - return this.hub && this.hub.octokit; - } - - constructor( - public remote: GitHubRemote, - public readonly rootUri: vscode.Uri, - private readonly _credentialStore: CredentialStore, - private readonly _telemetry: ITelemetry, - silent: boolean = false - ) { - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as unknown as Schema, defaultSchema as unknown as Schema); - // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring - if (!silent) { - this.ensureCommentsController(); - } - } - - get authMatchesServer(): boolean { - if ((this.remote.githubServerType === GitHubServerType.GitHubDotCom) && this._credentialStore.isAuthenticated(AuthProvider.github)) { - return true; - } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { - return true; - } else { - // Not good. We have a mismatch between auth type and server type. - return false; - } - } - - private codespacesTokenError(action: QueryOptions | MutationOptions) { - if (isInCodespaces() && this._metadata?.fork) { - // :( https://github.com/microsoft/vscode-pull-request-github/issues/5325#issuecomment-1798243852 - /* __GDPR__ - "pr.codespacesTokenError" : { - "action": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } - } - */ - this._telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', { - action: action.context - }); - - throw new Error(vscode.l10n.t('This action cannot be completed in a GitHub Codespace on a fork.')); - } - } - - query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode }): Promise> => { - const gql = this.authMatchesServer && this.hub && this.hub.graphql; - if (!gql) { - const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; - Logger.debug(`Not available for query: ${logValue ?? 'unknown'}`, GRAPHQL_COMPONENT_ID); - return { - data: null, - loading: false, - networkStatus: NetworkStatus.error, - stale: false, - } as any; - } - - let rsp; - try { - rsp = await gql.query(query); - } catch (e) { - if (legacyFallback) { - query.query = legacyFallback.query; - return this.query(query, ignoreSamlErrors); - } - - if (e.graphQLErrors && e.graphQLErrors.length && ((e.graphQLErrors as GraphQLError[]).some(error => error.extensions.code === 'undefinedField')) && !this._areQueriesLimited) { - // We're running against a GitHub server that doesn't support the query we're trying to run. - // Switch to the limited schema and try again. - this._areQueriesLimited = true; - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); - query.query = this.schema[(query.query.definitions[0] as { name: { value: string } }).name.value]; - rsp = await gql.query(query); - } else if (!ignoreSamlErrors && (e.message as string | undefined)?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { - // Some queries just result in SAML errors, and some queries we may not want to retry because it will be too disruptive. - await this._credentialStore.recreate(); - rsp = await gql.query(query); - } else if ((e.message as string | undefined)?.includes('401 Unauthorized')) { - await this._credentialStore.recreate(vscode.l10n.t('Your authentication session has lost authorization. You need to sign in again to regain authorization.')); - rsp = await gql.query(query); - } else { - if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { - this.codespacesTokenError(query); - } - throw e; - } - } - return rsp; - }; - - mutate = async (mutation: MutationOptions, legacyFallback?: { mutation: DocumentNode, deleteProps: string[] }): Promise> => { - const gql = this.authMatchesServer && this.hub && this.hub.graphql; - if (!gql) { - Logger.debug(`Not available for query: ${mutation.context as string}`, GRAPHQL_COMPONENT_ID); - return { - data: null, - loading: false, - networkStatus: NetworkStatus.error, - stale: false, - } as any; - } - - let rsp; - try { - rsp = await gql.mutate(mutation); - } catch (e) { - if (legacyFallback) { - mutation.mutation = legacyFallback.mutation; - if (mutation.variables?.input) { - for (const prop of legacyFallback.deleteProps) { - delete mutation.variables.input[prop]; - } - } - return this.mutate(mutation); - } else if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { - this.codespacesTokenError(mutation); - } - throw e; - } - return rsp; - }; - - get schema() { - return this._queriesSchema; - } - - async getMetadata(): Promise { - Logger.debug(`Fetch metadata - enter`, GitHubRepository.ID); - if (this._metadata) { - Logger.debug( - `Fetch metadata ${this._metadata.owner?.login}/${this._metadata.name} - done`, - GitHubRepository.ID, - ); - return this._metadata; - } - const { octokit, remote } = await this.ensure(); - const result = await octokit.call(octokit.api.repos.get, { - owner: remote.owner, - repo: remote.repositoryName, - }); - Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, GitHubRepository.ID); - this._metadata = ({ ...result.data, currentUser: (octokit as any).currentUser } as unknown) as IMetadata; - return this._metadata; - } - - /** - * Resolves remotes with redirects. - * @returns - */ - async resolveRemote(): Promise { - try { - const { clone_url } = await this.getMetadata(); - this.remote = GitHubRemote.remoteAsGitHub(parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!, this.remote.githubServerType); - } catch (e) { - Logger.warn(`Unable to resolve remote: ${e}`); - if (isSamlError(e)) { - return false; - } - } - return true; - } - - async ensure(additionalScopes: boolean = false): Promise { - this._initialized = true; - const oldHub = this._hub; - if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { - // We need auth now. (ex., a PR is already checked out) - // We can no longer wait until later for login to be done - await this._credentialStore.create(undefined, additionalScopes); - if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { - this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId); - } - } else { - if (additionalScopes) { - this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId); - } else { - this._hub = this._credentialStore.getHub(this.remote.authProviderId); - } - } - - if (oldHub !== this._hub) { - if (this._credentialStore.areScopesExtra(this.remote.authProviderId)) { - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, extraSchema.default as any); - } else if (this._credentialStore.areScopesOld(this.remote.authProviderId)) { - this._areQueriesLimited = true; - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); - } else { - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any); - } - } - return this; - } - - async ensureAdditionalScopes(): Promise { - return this.ensure(true); - } - - async getDefaultBranch(): Promise { - const overrideSetting = getOverrideBranch(); - if (overrideSetting) { - return overrideSetting; - } - try { - Logger.debug(`Fetch default branch - enter`, GitHubRepository.ID); - const data = await this.getMetadata(); - Logger.debug(`Fetch default branch - done`, GitHubRepository.ID); - - return data.default_branch; - } catch (e) { - Logger.warn(`Fetching default branch failed: ${e}`, GitHubRepository.ID); - } - - return 'master'; - } - - private _repoAccessAndMergeMethods: RepoAccessAndMergeMethods | undefined; - async getRepoAccessAndMergeMethods(refetch: boolean = false): Promise { - try { - if (!this._repoAccessAndMergeMethods || refetch) { - Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); - const data = await this.getMetadata(); - - Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); - const hasWritePermission = data.permissions?.push ?? false; - this._repoAccessAndMergeMethods = { - // Users with push access to repo have rights to merge/close PRs, - // edit title/description, assign reviewers/labels etc. - hasWritePermission, - mergeMethodsAvailability: { - merge: data.allow_merge_commit ?? false, - squash: data.allow_squash_merge ?? false, - rebase: data.allow_rebase_merge ?? false, - }, - viewerCanAutoMerge: ((data as any).allow_auto_merge && hasWritePermission) ?? false - }; - } - return this._repoAccessAndMergeMethods; - } catch (e) { - Logger.warn(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); - } - - return { - hasWritePermission: true, - mergeMethodsAvailability: { - merge: true, - squash: true, - rebase: true, - }, - viewerCanAutoMerge: false - }; - } - - private _branchHasMergeQueue: Map = new Map(); - async mergeQueueMethodForBranch(branch: string): Promise { - if (this._branchHasMergeQueue.has(branch)) { - return this._branchHasMergeQueue.get(branch)!; - } - try { - Logger.debug('Fetch branch has merge queue - enter', GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - if (!schema.MergeQueueForBranch) { - return undefined; - } - const result = await query({ - query: schema.MergeQueueForBranch, - variables: { - owner: remote.owner, - name: remote.repositoryName, - branch - } - }); - - Logger.debug('Fetch branch has merge queue - done', GitHubRepository.ID); - const mergeMethod = parseMergeMethod(result.data.repository.mergeQueue?.configuration?.mergeMethod); - if (mergeMethod) { - this._branchHasMergeQueue.set(branch, mergeMethod); - } - return mergeMethod; - } catch (e) { - Logger.error(`Fetching branch has merge queue failed: ${e}`, GitHubRepository.ID); - } - } - - async getAllPullRequests(page?: number): Promise { - try { - Logger.debug(`Fetch all pull requests - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const result = await octokit.call(octokit.api.pulls.list, { - owner: remote.owner, - repo: remote.repositoryName, - per_page: PULL_REQUEST_PAGE_SIZE, - page: page || 1, - }); - - const hasMorePages = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; - if (!result.data) { - // We really don't expect this to happen, but it seems to (see #574). - // Log a warning and return an empty set. - Logger.warn( - `No result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`, - ); - return { - items: [], - hasMorePages: false, - }; - } - - const pullRequests = result.data - .map(pullRequest => { - if (!pullRequest.head.repo) { - Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); - return null; - } - - return this.createOrUpdatePullRequestModel( - convertRESTPullRequestToRawPullRequest(pullRequest, this), - ); - }) - .filter(item => item !== null) as PullRequestModel[]; - - Logger.debug(`Fetch all pull requests - done`, GitHubRepository.ID); - return { - items: pullRequests, - hasMorePages, - }; - } catch (e) { - Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { - // not found - vscode.window.showWarningMessage( - `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, - ); - } else { - throw e; - } - } - return undefined; - } - - async getPullRequestForBranch(branch: string, headOwner: string): Promise { - try { - Logger.debug(`Fetch pull requests for branch - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.PullRequestForHead, - variables: { - owner: remote.owner, - name: remote.repositoryName, - headRefName: branch, - }, - }); - Logger.debug(`Fetch pull requests for branch - done`, GitHubRepository.ID); - - if (data?.repository && data.repository.pullRequests.nodes.length > 0) { - const prs = data.repository.pullRequests.nodes.map(node => parseGraphQLPullRequest(node, this)).filter(pr => pr.head?.repo.owner === headOwner); - if (prs.length === 0) { - return undefined; - } - const mostRecentOrOpenPr = prs.find(pr => pr.state.toLowerCase() === 'open') ?? prs[0]; - return this.createOrUpdatePullRequestModel(mostRecentOrOpenPr); - } - } catch (e) { - Logger.error(`Fetching pull requests for branch failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { - // not found - vscode.window.showWarningMessage( - `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, - ); - } - } - return undefined; - } - - async getOrgProjects(): Promise { - Logger.debug(`Fetch org projects - enter`, GitHubRepository.ID); - let { query, remote, schema } = await this.ensure(); - const projects: IProject[] = []; - - try { - const { data } = await query({ - query: schema.GetOrgProjects, - variables: { - owner: remote.owner, - after: null, - } - }); - - if (data && data.organization.projectsV2 && data.organization.projectsV2.nodes) { - data.organization.projectsV2.nodes.forEach(raw => { - projects.push(raw); - }); - } - - } catch (e) { - Logger.error(`Unable to fetch org projects: ${e}`, GitHubRepository.ID); - return projects; - } - Logger.debug(`Fetch org projects - done`, GitHubRepository.ID); - - return projects; - } - - async getProjects(): Promise { - try { - Logger.debug(`Fetch projects - enter`, GitHubRepository.ID); - let { query, remote, schema } = await this.ensure(); - if (!schema.GetRepoProjects) { - const additional = await this.ensureAdditionalScopes(); - query = additional.query; - remote = additional.remote; - schema = additional.schema; - } - const { data } = await query({ - query: schema.GetRepoProjects, - variables: { - owner: remote.owner, - name: remote.repositoryName, - }, - }); - Logger.debug(`Fetch projects - done`, GitHubRepository.ID); - - const projects: IProject[] = []; - if (data && data.repository?.projectsV2 && data.repository.projectsV2.nodes) { - data.repository.projectsV2.nodes.forEach(raw => { - projects.push(raw); - }); - } - return projects; - } catch (e) { - Logger.error(`Unable to fetch projects: ${e}`, GitHubRepository.ID); - return; - } - } - - async getMilestones(includeClosed: boolean = false): Promise { - try { - Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const states = ['OPEN']; - if (includeClosed) { - states.push('CLOSED'); - } - const { data } = await query({ - query: schema.GetMilestones, - variables: { - owner: remote.owner, - name: remote.repositoryName, - states: states, - }, - }); - Logger.debug(`Fetch milestones - done`, GitHubRepository.ID); - - const milestones: IMilestone[] = []; - if (data && data.repository?.milestones && data.repository.milestones.nodes) { - data.repository.milestones.nodes.forEach(raw => { - const milestone = parseMilestone(raw); - if (milestone) { - milestones.push(milestone); - } - }); - } - return milestones; - } catch (e) { - Logger.error(`Unable to fetch milestones: ${e}`, GitHubRepository.ID); - return; - } - } - - async getLines(sha: string, file: string, lineStart: number, lineEnd: number): Promise { - Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetFileContent, - variables: { - owner: remote.owner, - name: remote.repositoryName, - expression: `${sha}:${file}` - } - }); - - if (!data.repository?.object.text) { - return undefined; - } - - return data.repository.object.text.split('\n').slice(lineStart - 1, lineEnd).join('\n'); - } - - async getIssuesForUserByMilestone(_page?: number): Promise { - try { - Logger.debug(`Fetch all issues - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetMilestonesWithIssues, - variables: { - owner: remote.owner, - name: remote.repositoryName, - assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, - }, - }); - Logger.debug(`Fetch all issues - done`, GitHubRepository.ID); - - const milestones: { milestone: IMilestone; issues: IssueModel[] }[] = []; - if (data && data.repository?.milestones && data.repository.milestones.nodes) { - data.repository.milestones.nodes.forEach(raw => { - const milestone = parseMilestone(raw); - if (milestone) { - const issues: IssueModel[] = []; - raw.issues.edges.forEach(issue => { - issues.push(new IssueModel(this, this.remote, parseGraphQLIssue(issue.node, this))); - }); - milestones.push({ milestone, issues }); - } - }); - } - return { - items: milestones, - hasMorePages: !!data.repository?.milestones.pageInfo.hasNextPage, - }; - } catch (e) { - Logger.error(`Unable to fetch issues: ${e}`, GitHubRepository.ID); - return; - } - } - - async getIssuesWithoutMilestone(_page?: number): Promise { - try { - Logger.debug(`Fetch issues without milestone- enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.IssuesWithoutMilestone, - variables: { - owner: remote.owner, - name: remote.repositoryName, - assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, - }, - }); - Logger.debug(`Fetch issues without milestone - done`, GitHubRepository.ID); - - const issues: Issue[] = []; - if (data && data.repository?.issues.edges) { - data.repository.issues.edges.forEach(raw => { - if (raw.node.id) { - issues.push(parseGraphQLIssue(raw.node, this)); - } - }); - } - return { - items: issues, - hasMorePages: !!data.repository?.issues.pageInfo.hasNextPage, - }; - } catch (e) { - Logger.error(`Unable to fetch issues without milestone: ${e}`, GitHubRepository.ID); - return; - } - } - - async getIssues(page?: number, queryString?: string): Promise { - try { - Logger.debug(`Fetch issues with query - enter`, GitHubRepository.ID); - const { query, schema } = await this.ensure(); - const { data } = await query({ - query: schema.Issues, - variables: { - query: `${queryString} type:issue`, - }, - }); - Logger.debug(`Fetch issues with query - done`, GitHubRepository.ID); - - const issues: Issue[] = []; - if (data && data.search.edges) { - data.search.edges.forEach(raw => { - if (raw.node.id) { - issues.push(parseGraphQLIssue(raw.node, this)); - } - }); - } - return { - items: issues, - hasMorePages: data.search.pageInfo.hasNextPage, - }; - } catch (e) { - Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); - return; - } - } - - async getMaxIssue(): Promise { - try { - Logger.debug(`Fetch max issue - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.MaxIssue, - variables: { - owner: remote.owner, - name: remote.repositoryName, - }, - }); - Logger.debug(`Fetch max issue - done`, GitHubRepository.ID); - - if (data?.repository && data.repository.issues.edges.length === 1) { - return data.repository.issues.edges[0].node.number; - } - return; - } catch (e) { - Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); - return; - } - } - - async getViewerPermission(): Promise { - try { - Logger.debug(`Fetch viewer permission - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetViewerPermission, - variables: { - owner: remote.owner, - name: remote.repositoryName, - }, - }); - Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); - return parseGraphQLViewerPermission(data); - } catch (e) { - Logger.error(`Unable to fetch viewer permission: ${e}`, GitHubRepository.ID); - return ViewerPermission.Unknown; - } - } - - async fork(): Promise { - try { - Logger.debug(`Fork repository`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const result = await octokit.call(octokit.api.repos.createFork, { - owner: remote.owner, - repo: remote.repositoryName, - }); - return result.data.clone_url; - } catch (e) { - Logger.error(`GitHubRepository> Forking repository failed: ${e}`, GitHubRepository.ID); - return undefined; - } - } - - async getRepositoryForkDetails(): Promise { - try { - Logger.debug(`Fetch repository fork details - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetRepositoryForkDetails, - variables: { - owner: remote.owner, - name: remote.repositoryName, - }, - }); - Logger.debug(`Fetch repository fork details - done`, GitHubRepository.ID); - return data.repository; - } catch (e) { - Logger.error(`Unable to fetch repository fork details: ${e}`, GitHubRepository.ID); - return; - } - } - - async getAuthenticatedUser(): Promise { - return (await this._credentialStore.getCurrentUser(this.remote.authProviderId)).login; - } - - async getPullRequestsForCategory(categoryQuery: string, page?: number): Promise { - try { - Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, GitHubRepository.ID); - const { octokit, query, schema } = await this.ensure(); - - const user = await this.getAuthenticatedUser(); - // Search api will not try to resolve repo that redirects, so get full name first - const repo = await this.getMetadata(); - const { data, headers } = await octokit.call(octokit.api.search.issuesAndPullRequests, { - q: getPRFetchQuery(repo.full_name, user, categoryQuery), - per_page: PULL_REQUEST_PAGE_SIZE, - page: page || 1, - }); - - const promises: Promise[] = data.items.map(async (item) => { - const prRepo = new Protocol(item.repository_url); - const { data } = await query({ - query: schema.PullRequest, - variables: { - owner: prRepo.owner, - name: prRepo.repositoryName, - number: item.number - } - }); - return data; - }); - - const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; - const pullRequestResponses = await Promise.all(promises); - - const pullRequests = pullRequestResponses - .map(response => { - if (!response.repository?.pullRequest.headRef) { - Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); - return null; - } - - return this.createOrUpdatePullRequestModel( - parseGraphQLPullRequest(response.repository.pullRequest, this), - ); - }) - .filter(item => item !== null) as PullRequestModel[]; - - Logger.debug(`Fetch pull request category ${categoryQuery} - done`, GitHubRepository.ID); - - return { - items: pullRequests, - hasMorePages, - }; - } catch (e) { - Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { - // not found - vscode.window.showWarningMessage( - `Fetching pull requests for remote ${this.remote.remoteName}, please check if the url ${this.remote.url} is valid.`, - ); - } else { - throw e; - } - } - return undefined; - } - - createOrUpdatePullRequestModel(pullRequest: PullRequest): PullRequestModel { - let model = this._pullRequestModels.get(pullRequest.number); - if (model) { - model.update(pullRequest); - } else { - model = new PullRequestModel(this._credentialStore, this._telemetry, this, this.remote, pullRequest); - model.onDidInvalidate(() => this.getPullRequest(pullRequest.number)); - this._pullRequestModels.set(pullRequest.number, model); - this._onDidAddPullRequest.fire(model); - } - - return model; - } - - async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { - try { - Logger.debug(`Create pull request - enter`, GitHubRepository.ID); - const metadata = await this.getMetadata(); - const { mutate, schema } = await this.ensure(); - - const { data } = await mutate({ - mutation: schema.CreatePullRequest, - variables: { - input: { - repositoryId: metadata.node_id, - baseRefName: params.base, - headRefName: params.head, - title: params.title, - body: params.body, - draft: params.draft - } - } - }); - Logger.debug(`Create pull request - done`, GitHubRepository.ID); - if (!data) { - throw new Error('Failed to create pull request.'); - } - return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); - } catch (e) { - Logger.error(`Unable to create PR: ${e}`, GitHubRepository.ID); - throw e; - } - } - - async getPullRequest(id: number): Promise { - try { - Logger.debug(`Fetch pull request ${id} - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const { data } = await query({ - query: schema.PullRequest, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: id, - }, - }, true); - if (data.repository === null) { - Logger.error('Unexpected null repository when getting PR', GitHubRepository.ID); - return; - } - - Logger.debug(`Fetch pull request ${id} - done`, GitHubRepository.ID); - return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.repository.pullRequest, this)); - } catch (e) { - Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); - return; - } - } - - async getIssue(id: number, withComments: boolean = false): Promise { - try { - Logger.debug(`Fetch issue ${id} - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const { data } = await query({ - query: withComments ? schema.IssueWithComments : schema.Issue, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: id, - }, - }, true); // Don't retry on SAML errors as it's too disruptive for this query. - - if (data.repository === null) { - Logger.error('Unexpected null repository when getting issue', GitHubRepository.ID); - return undefined; - } - Logger.debug(`Fetch issue ${id} - done`, GitHubRepository.ID); - - return new IssueModel(this, remote, parseGraphQLIssue(data.repository.pullRequest, this)); - } catch (e) { - Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); - return; - } - } - - async hasBranch(branchName: string): Promise { - Logger.appendLine(`Fetch branch ${branchName} - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const { data } = await query({ - query: schema.GetBranch, - variables: { - owner: remote.owner, - name: remote.repositoryName, - qualifiedName: `refs/heads/${branchName}`, - } - }); - Logger.appendLine(`Fetch branch ${branchName} - done: ${data.repository?.ref !== null}`, GitHubRepository.ID); - return data.repository?.ref !== null; - } - - async listBranches(owner: string, repositoryName: string): Promise { - const { query, remote, schema } = await this.ensure(); - Logger.debug(`List branches for ${owner}/${repositoryName} - enter`, GitHubRepository.ID); - - let after: string | null = null; - let hasNextPage = false; - const branches: string[] = []; - const startingTime = new Date().getTime(); - - do { - try { - const { data } = await query({ - query: schema.ListBranches, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after, - }, - }); - - branches.push(...data.repository.refs.nodes.map(node => node.name)); - if (new Date().getTime() - startingTime > 5000) { - Logger.warn('List branches timeout hit.', GitHubRepository.ID); - break; - } - hasNextPage = data.repository.refs.pageInfo.hasNextPage; - after = data.repository.refs.pageInfo.endCursor; - } catch (e) { - Logger.debug(`List branches for ${owner}/${repositoryName} failed`, GitHubRepository.ID); - throw e; - } - } while (hasNextPage); - - Logger.debug(`List branches for ${owner}/${repositoryName} - done`, GitHubRepository.ID); - return branches; - } - - async deleteBranch(pullRequestModel: PullRequestModel): Promise { - const { octokit } = await this.ensure(); - - if (!pullRequestModel.validatePullRequestModel('Unable to delete branch')) { - return; - } - - try { - await octokit.call(octokit.api.git.deleteRef, { - owner: pullRequestModel.head.repositoryCloneUrl.owner, - repo: pullRequestModel.head.repositoryCloneUrl.repositoryName, - ref: `heads/${pullRequestModel.head.ref}`, - }); - } catch (e) { - Logger.error(`Unable to delete branch: ${e}`, GitHubRepository.ID); - return; - } - } - - async getMentionableUsers(): Promise { - Logger.debug(`Fetch mentionable users - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - let after: string | null = null; - let hasNextPage = false; - const ret: IAccount[] = []; - - do { - try { - const result: { data: MentionableUsersResponse } = await query({ - query: schema.GetMentionableUsers, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after, - }, - }); - - if (result.data.repository === null) { - Logger.error('Unexpected null repository when getting mentionable users', GitHubRepository.ID); - return []; - } - - ret.push( - ...result.data.repository.mentionableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), - name: node.name, - url: node.url, - email: node.email, - id: node.id - }; - }), - ); - - hasNextPage = result.data.repository.mentionableUsers.pageInfo.hasNextPage; - after = result.data.repository.mentionableUsers.pageInfo.endCursor; - } catch (e) { - Logger.debug(`Unable to fetch mentionable users: ${e}`, GitHubRepository.ID); - return ret; - } - } while (hasNextPage); - - return ret; - } - - async getAssignableUsers(): Promise { - Logger.debug(`Fetch assignable users - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - let after: string | null = null; - let hasNextPage = false; - const ret: IAccount[] = []; - - do { - try { - const result: { data: AssignableUsersResponse } = await query({ - query: schema.GetAssignableUsers, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after, - }, - }, true); // we ignore SAML errors here because this query can happen at startup - - if (result.data.repository === null) { - Logger.error('Unexpected null repository when getting assignable users', GitHubRepository.ID); - return []; - } - - ret.push( - ...result.data.repository.assignableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), - name: node.name, - url: node.url, - email: node.email, - id: node.id - }; - }), - ); - - hasNextPage = result.data.repository.assignableUsers.pageInfo.hasNextPage; - after = result.data.repository.assignableUsers.pageInfo.endCursor; - } catch (e) { - Logger.debug(`Unable to fetch assignable users: ${e}`, GitHubRepository.ID); - if ( - e.graphQLErrors && - e.graphQLErrors.length > 0 && - e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' - ) { - vscode.window.showWarningMessage( - `GitHub user features will not work. ${e.graphQLErrors[0].message}`, - ); - } - return ret; - } - } while (hasNextPage); - - return ret; - } - - async getOrgTeamsCount(): Promise { - Logger.debug(`Fetch Teams Count - enter`, GitHubRepository.ID); - if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) { - return 0; - } - - const { query, remote, schema } = await this.ensureAdditionalScopes(); - - try { - const result: { data: OrganizationTeamsCountResponse } = await query({ - query: schema.GetOrganizationTeamsCount, - variables: { - login: remote.owner - }, - }); - return result.data.organization.teams.totalCount; - } catch (e) { - Logger.debug(`Unable to fetch teams Count: ${e}`, GitHubRepository.ID); - if ( - e.graphQLErrors && - e.graphQLErrors.length > 0 && - e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' - ) { - vscode.window.showWarningMessage( - `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, - ); - } - return 0; - } - } - - async getOrgTeams(refreshKind: TeamReviewerRefreshKind): Promise<(ITeam & { repositoryNames: string[] })[]> { - Logger.debug(`Fetch Teams - enter`, GitHubRepository.ID); - if ((refreshKind === TeamReviewerRefreshKind.None) || (refreshKind === TeamReviewerRefreshKind.Try && !this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId))) { - Logger.debug(`Fetch Teams - exit without fetching teams`, GitHubRepository.ID); - return []; - } - - const { query, remote, schema } = await this.ensureAdditionalScopes(); - - let after: string | null = null; - let hasNextPage = false; - const orgTeams: (ITeam & { repositoryNames: string[] })[] = []; - - do { - try { - const result: { data: OrganizationTeamsResponse } = await query({ - query: schema.GetOrganizationTeams, - variables: { - login: remote.owner, - after: after, - repoName: remote.repositoryName, - }, - }); - - result.data.organization.teams.nodes.forEach(node => { - const team: ITeam = { - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), - name: node.name, - url: node.url, - slug: node.slug, - id: node.id, - org: remote.owner - }; - orgTeams.push({ ...team, repositoryNames: node.repositories.nodes.map(repo => repo.name) }); - }); - - hasNextPage = result.data.organization.teams.pageInfo.hasNextPage; - after = result.data.organization.teams.pageInfo.endCursor; - } catch (e) { - Logger.debug(`Unable to fetch teams: ${e}`, GitHubRepository.ID); - if ( - e.graphQLErrors && - e.graphQLErrors.length > 0 && - e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' - ) { - vscode.window.showWarningMessage( - `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, - ); - } - return orgTeams; - } - } while (hasNextPage); - - Logger.debug(`Fetch Teams - exit`, GitHubRepository.ID); - return orgTeams; - } - - async getPullRequestParticipants(pullRequestNumber: number): Promise { - Logger.debug(`Fetch participants from a Pull Request`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const ret: IAccount[] = []; - - try { - const result: { data: PullRequestParticipantsResponse } = await query({ - query: schema.GetParticipants, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: pullRequestNumber, - first: 18 - }, - }); - if (result.data.repository === null) { - Logger.error('Unexpected null repository when fetching participants', GitHubRepository.ID); - return []; - } - - ret.push( - ...result.data.repository.pullRequest.participants.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), - name: node.name, - url: node.url, - email: node.email, - id: node.id - }; - }), - ); - } catch (e) { - Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, GitHubRepository.ID); - if ( - e.graphQLErrors && - e.graphQLErrors.length > 0 && - e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' - ) { - vscode.window.showWarningMessage( - `GitHub user features will not work. ${e.graphQLErrors[0].message}`, - ); - } - } - - return ret; - } - - /** - * Compare across commits. - * @param base The base branch. Must be a branch name. If comparing across repositories, use the format :branch. - * @param head The head branch. Must be a branch name. If comparing across repositories, use the format :branch. - */ - public async compareCommits(base: string, head: string): Promise { - Logger.debug('Compare commits - enter', GitHubRepository.ID); - try { - const { remote, octokit } = await this.ensure(); - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base, - head, - }); - Logger.debug('Compare commits - done', GitHubRepository.ID); - return data; - } catch (e) { - Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, GitHubRepository.ID); - } - } - - isCurrentUser(login: string): Promise { - return this._credentialStore.isCurrentUser(login); - } - - /** - * Get the status checks of the pull request, those for the last commit. - * - * This method should go in PullRequestModel, but because of the status checks bug we want to track `_useFallbackChecks` at a repo level. - */ - private _useFallbackChecks: boolean = false; - async getStatusChecks(number: number): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { - const { query, remote, schema } = await this.ensure(); - const captureUseFallbackChecks = this._useFallbackChecks; - let result: ApolloQueryResult; - try { - result = await query({ - query: captureUseFallbackChecks ? schema.GetChecksWithoutSuite : schema.GetChecks, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: number, - }, - }, true); // There's an issue with the GetChecks that can result in SAML errors. - } catch (e) { - if (e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { - // There seems to be an issue with fetching status checks if you haven't SAML'd with every org you have - // The issue is specifically with the CheckSuite property. Make the query again, but without that property. - if (!captureUseFallbackChecks) { - this._useFallbackChecks = true; - return this.getStatusChecks(number); - } - } - throw e; - } - - if ((result.data.repository === null) || (result.data.repository.pullRequest.commits.nodes === undefined) || (result.data.repository.pullRequest.commits.nodes.length === 0)) { - Logger.error(`Unable to fetch PR checks: ${result.errors?.map(error => error.message).join(', ')}`, GitHubRepository.ID); - return [null, null]; - } - - // We always fetch the status checks for only the last commit, so there should only be one node present - const statusCheckRollup = result.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup; - - const checks: PullRequestChecks = !statusCheckRollup - ? { - state: CheckState.Success, - statuses: [] - } - : { - state: this.mapStateAsCheckState(statusCheckRollup.state), - statuses: statusCheckRollup.contexts.nodes.map(context => { - if (isCheckRun(context)) { - return { - id: context.id, - url: context.checkSuite?.app?.url, - avatarUrl: - context.checkSuite?.app?.logoUrl && - getAvatarWithEnterpriseFallback( - context.checkSuite.app.logoUrl, - undefined, - this.remote.isEnterprise, - ), - state: this.mapStateAsCheckState(context.conclusion), - description: context.title, - context: context.name, - targetUrl: context.detailsUrl, - isRequired: context.isRequired, - }; - } else { - return { - id: context.id, - url: context.targetUrl ?? undefined, - avatarUrl: context.avatarUrl - ? getAvatarWithEnterpriseFallback(context.avatarUrl, undefined, this.remote.isEnterprise) - : undefined, - state: this.mapStateAsCheckState(context.state), - description: context.description, - context: context.context, - targetUrl: context.targetUrl, - isRequired: context.isRequired, - }; - } - }), - }; - - let reviewRequirement: PullRequestReviewRequirement | null = null; - const rule = result.data.repository.pullRequest.baseRef.refUpdateRule; - if (rule) { - const prUrl = result.data.repository.pullRequest.url; - - for (const context of rule.requiredStatusCheckContexts || []) { - if (!checks.statuses.some(status => status.context === context)) { - checks.state = CheckState.Pending; - checks.statuses.push({ - id: '', - url: undefined, - avatarUrl: undefined, - state: CheckState.Pending, - description: vscode.l10n.t('Waiting for status to be reported'), - context: context, - targetUrl: prUrl, - isRequired: true - }); - } - } - - const requiredApprovingReviews = rule.requiredApprovingReviewCount ?? 0; - const approvingReviews = result.data.repository.pullRequest.latestReviews.nodes.filter( - review => review.authorCanPushToRepository && review.state === 'APPROVED', - ); - const requestedChanges = result.data.repository.pullRequest.reviewsRequestingChanges.nodes.filter( - review => review.authorCanPushToRepository - ); - let state: CheckState = CheckState.Success; - if (approvingReviews.length < requiredApprovingReviews) { - state = CheckState.Failure; - - if (requestedChanges.length) { - state = CheckState.Pending; - } - } - if (requiredApprovingReviews > 0) { - reviewRequirement = { - count: requiredApprovingReviews, - approvals: approvingReviews.map(review => review.author.login), - requestedChanges: requestedChanges.map(review => review.author.login), - state: state - }; - } - } - - return [checks.statuses.length ? checks : null, reviewRequirement]; - } - - mapStateAsCheckState(state: string | null | undefined): CheckState { - switch (state) { - case 'EXPECTED': - case 'PENDING': - case 'ACTION_REQUIRED': - case 'STALE': - return CheckState.Pending; - case 'ERROR': - case 'FAILURE': - case 'TIMED_OUT': - case 'STARTUP_FAILURE': - return CheckState.Failure; - case 'SUCCESS': - return CheckState.Success; - case 'NEUTRAL': - case 'SKIPPED': - return CheckState.Neutral; - } - - return CheckState.Unknown; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; +import * as vscode from 'vscode'; +import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { GitHubRemote, parseRemote } from '../common/remote'; +import { ITelemetry } from '../common/telemetry'; +import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; +import { mergeQuerySchemaWithShared, OctokitCommon, Schema } from './common'; +import { CredentialStore, GitHub } from './credentials'; +import { + AssignableUsersResponse, + CreatePullRequestResponse, + FileContentResponse, + ForkDetailsResponse, + GetBranchResponse, + GetChecksResponse, + isCheckRun, + IssuesResponse, + IssuesSearchResponse, + ListBranchesResponse, + MaxIssueResponse, + MentionableUsersResponse, + MergeQueueForBranchResponse, + MilestoneIssuesResponse, + OrganizationTeamsCountResponse, + OrganizationTeamsResponse, + OrgProjectsResponse, + PullRequestParticipantsResponse, + PullRequestResponse, + PullRequestsResponse, + RepoProjectsResponse, + ViewerPermissionResponse, +} from './graphql'; +import { + CheckState, + IAccount, + IMilestone, + IProject, + Issue, + ITeam, + MergeMethod, + PullRequest, + PullRequestChecks, + PullRequestReviewRequirement, + RepoAccessAndMergeMethods, +} from './interface'; +import { IssueModel } from './issueModel'; +import { LoggingOctokit } from './loggingOctokit'; +import { PullRequestModel } from './pullRequestModel'; +import defaultSchema from './queries.gql'; +import * as extraSchema from './queriesExtra.gql'; +import * as limitedSchema from './queriesLimited.gql'; +import * as sharedSchema from './queriesShared.gql'; +import { + convertRESTPullRequestToRawPullRequest, + getAvatarWithEnterpriseFallback, + getOverrideBranch, + getPRFetchQuery, + isInCodespaces, + parseGraphQLIssue, + parseGraphQLPullRequest, + parseGraphQLViewerPermission, + parseMergeMethod, + parseMilestone, +} from './utils'; + +export const PULL_REQUEST_PAGE_SIZE = 20; + +const GRAPHQL_COMPONENT_ID = 'GraphQL'; + +export interface ItemsData { + items: any[]; + hasMorePages: boolean; +} + +export interface IssueData extends ItemsData { + items: Issue[]; + hasMorePages: boolean; +} + +export interface PullRequestData extends ItemsData { + items: PullRequestModel[]; +} + +export interface MilestoneData extends ItemsData { + items: { milestone: IMilestone; issues: IssueModel[] }[]; + hasMorePages: boolean; +} + +export enum ViewerPermission { + Unknown = 'unknown', + Admin = 'ADMIN', + Maintain = 'MAINTAIN', + Read = 'READ', + Triage = 'TRIAGE', + Write = 'WRITE', +} + +export enum TeamReviewerRefreshKind { + None, + Try, + Force +} + +export interface ForkDetails { + isFork: boolean; + parent: { + owner: { + login: string; + }; + name: string; + }; +} + +export interface IMetadata extends OctokitCommon.ReposGetResponseData { + currentUser: any; +} + +interface GraphQLError { + extensions: { + code: string; + }; +} + +export class GitHubRepository implements vscode.Disposable { + static ID = 'GitHubRepository'; + protected _initialized: boolean = false; + protected _hub: GitHub | undefined; + protected _metadata: IMetadata | undefined; + private _toDispose: vscode.Disposable[] = []; + public commentsController?: vscode.CommentController; + public commentsHandler?: PRCommentControllerRegistry; + private _pullRequestModels = new Map(); + private _queriesSchema: any; + private _areQueriesLimited: boolean = false; + + private _onDidAddPullRequest: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; + + public get hub(): GitHub { + if (!this._hub) { + if (!this._initialized) { + throw new Error('Call ensure() before accessing this property.'); + } else { + throw new AuthenticationError('Not authenticated.'); + } + } + return this._hub; + } + + public equals(repo: GitHubRepository): boolean { + return this.remote.equals(repo.remote); + } + + get pullRequestModels(): Map { + return this._pullRequestModels; + } + + public async ensureCommentsController(): Promise { + try { + if (this.commentsController) { + return; + } + + await this.ensure(); + this.commentsController = vscode.comments.createCommentController( + `github-browse-${this.remote.normalizedHost}-${this.remote.owner}-${this.remote.repositoryName}`, + `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, + ); + this.commentsHandler = new PRCommentControllerRegistry(this.commentsController); + this._toDispose.push(this.commentsHandler); + this._toDispose.push(this.commentsController); + } catch (e) { + console.log(e); + } + } + + dispose() { + this._toDispose.forEach(d => d.dispose()); + this._toDispose = []; + this.commentsController = undefined; + this.commentsHandler = undefined; + } + + public get octokit(): LoggingOctokit { + return this.hub && this.hub.octokit; + } + + constructor( + public remote: GitHubRemote, + public readonly rootUri: vscode.Uri, + private readonly _credentialStore: CredentialStore, + private readonly _telemetry: ITelemetry, + silent: boolean = false + ) { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as unknown as Schema, defaultSchema as unknown as Schema); + // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring + if (!silent) { + this.ensureCommentsController(); + } + } + + get authMatchesServer(): boolean { + if ((this.remote.githubServerType === GitHubServerType.GitHubDotCom) && this._credentialStore.isAuthenticated(AuthProvider.github)) { + return true; + } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + return true; + } else { + // Not good. We have a mismatch between auth type and server type. + return false; + } + } + + private codespacesTokenError(action: QueryOptions | MutationOptions) { + if (isInCodespaces() && this._metadata?.fork) { + // :( https://github.com/microsoft/vscode-pull-request-github/issues/5325#issuecomment-1798243852 + /* __GDPR__ + "pr.codespacesTokenError" : { + "action": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this._telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', { + action: action.context + }); + + throw new Error(vscode.l10n.t('This action cannot be completed in a GitHub Codespace on a fork.')); + } + } + + query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode }): Promise> => { + const gql = this.authMatchesServer && this.hub && this.hub.graphql; + if (!gql) { + const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + Logger.debug(`Not available for query: ${logValue ?? 'unknown'}`, GRAPHQL_COMPONENT_ID); + return { + data: null, + loading: false, + networkStatus: NetworkStatus.error, + stale: false, + } as any; + } + + let rsp; + try { + rsp = await gql.query(query); + } catch (e) { + if (legacyFallback) { + query.query = legacyFallback.query; + return this.query(query, ignoreSamlErrors); + } + + if (e.graphQLErrors && e.graphQLErrors.length && ((e.graphQLErrors as GraphQLError[]).some(error => error.extensions.code === 'undefinedField')) && !this._areQueriesLimited) { + // We're running against a GitHub server that doesn't support the query we're trying to run. + // Switch to the limited schema and try again. + this._areQueriesLimited = true; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + query.query = this.schema[(query.query.definitions[0] as { name: { value: string } }).name.value]; + rsp = await gql.query(query); + } else if (!ignoreSamlErrors && (e.message as string | undefined)?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + // Some queries just result in SAML errors, and some queries we may not want to retry because it will be too disruptive. + await this._credentialStore.recreate(); + rsp = await gql.query(query); + } else if ((e.message as string | undefined)?.includes('401 Unauthorized')) { + await this._credentialStore.recreate(vscode.l10n.t('Your authentication session has lost authorization. You need to sign in again to regain authorization.')); + rsp = await gql.query(query); + } else { + if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + this.codespacesTokenError(query); + } + throw e; + } + } + return rsp; + }; + + mutate = async (mutation: MutationOptions, legacyFallback?: { mutation: DocumentNode, deleteProps: string[] }): Promise> => { + const gql = this.authMatchesServer && this.hub && this.hub.graphql; + if (!gql) { + Logger.debug(`Not available for query: ${mutation.context as string}`, GRAPHQL_COMPONENT_ID); + return { + data: null, + loading: false, + networkStatus: NetworkStatus.error, + stale: false, + } as any; + } + + let rsp; + try { + rsp = await gql.mutate(mutation); + } catch (e) { + if (legacyFallback) { + mutation.mutation = legacyFallback.mutation; + if (mutation.variables?.input) { + for (const prop of legacyFallback.deleteProps) { + delete mutation.variables.input[prop]; + } + } + return this.mutate(mutation); + } else if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + this.codespacesTokenError(mutation); + } + throw e; + } + return rsp; + }; + + get schema() { + return this._queriesSchema; + } + + async getMetadata(): Promise { + Logger.debug(`Fetch metadata - enter`, GitHubRepository.ID); + if (this._metadata) { + Logger.debug( + `Fetch metadata ${this._metadata.owner?.login}/${this._metadata.name} - done`, + GitHubRepository.ID, + ); + return this._metadata; + } + const { octokit, remote } = await this.ensure(); + const result = await octokit.call(octokit.api.repos.get, { + owner: remote.owner, + repo: remote.repositoryName, + }); + Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, GitHubRepository.ID); + this._metadata = ({ ...result.data, currentUser: (octokit as any).currentUser } as unknown) as IMetadata; + return this._metadata; + } + + /** + * Resolves remotes with redirects. + * @returns + */ + async resolveRemote(): Promise { + try { + const { clone_url } = await this.getMetadata(); + this.remote = GitHubRemote.remoteAsGitHub(parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!, this.remote.githubServerType); + } catch (e) { + Logger.warn(`Unable to resolve remote: ${e}`); + if (isSamlError(e)) { + return false; + } + } + return true; + } + + async ensure(additionalScopes: boolean = false): Promise { + this._initialized = true; + const oldHub = this._hub; + if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { + // We need auth now. (ex., a PR is already checked out) + // We can no longer wait until later for login to be done + await this._credentialStore.create(undefined, additionalScopes); + if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { + this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId); + } + } else { + if (additionalScopes) { + this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId); + } else { + this._hub = this._credentialStore.getHub(this.remote.authProviderId); + } + } + + if (oldHub !== this._hub) { + if (this._credentialStore.areScopesExtra(this.remote.authProviderId)) { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, extraSchema.default as any); + } else if (this._credentialStore.areScopesOld(this.remote.authProviderId)) { + this._areQueriesLimited = true; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + } else { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any); + } + } + return this; + } + + async ensureAdditionalScopes(): Promise { + return this.ensure(true); + } + + async getDefaultBranch(): Promise { + const overrideSetting = getOverrideBranch(); + if (overrideSetting) { + return overrideSetting; + } + try { + Logger.debug(`Fetch default branch - enter`, GitHubRepository.ID); + const data = await this.getMetadata(); + Logger.debug(`Fetch default branch - done`, GitHubRepository.ID); + + return data.default_branch; + } catch (e) { + Logger.warn(`Fetching default branch failed: ${e}`, GitHubRepository.ID); + } + + return 'master'; + } + + private _repoAccessAndMergeMethods: RepoAccessAndMergeMethods | undefined; + async getRepoAccessAndMergeMethods(refetch: boolean = false): Promise { + try { + if (!this._repoAccessAndMergeMethods || refetch) { + Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); + const data = await this.getMetadata(); + + Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); + const hasWritePermission = data.permissions?.push ?? false; + this._repoAccessAndMergeMethods = { + // Users with push access to repo have rights to merge/close PRs, + // edit title/description, assign reviewers/labels etc. + hasWritePermission, + mergeMethodsAvailability: { + merge: data.allow_merge_commit ?? false, + squash: data.allow_squash_merge ?? false, + rebase: data.allow_rebase_merge ?? false, + }, + viewerCanAutoMerge: ((data as any).allow_auto_merge && hasWritePermission) ?? false + }; + } + return this._repoAccessAndMergeMethods; + } catch (e) { + Logger.warn(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); + } + + return { + hasWritePermission: true, + mergeMethodsAvailability: { + merge: true, + squash: true, + rebase: true, + }, + viewerCanAutoMerge: false + }; + } + + private _branchHasMergeQueue: Map = new Map(); + async mergeQueueMethodForBranch(branch: string): Promise { + if (this._branchHasMergeQueue.has(branch)) { + return this._branchHasMergeQueue.get(branch)!; + } + try { + Logger.debug('Fetch branch has merge queue - enter', GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + if (!schema.MergeQueueForBranch) { + return undefined; + } + const result = await query({ + query: schema.MergeQueueForBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + branch + } + }); + + Logger.debug('Fetch branch has merge queue - done', GitHubRepository.ID); + const mergeMethod = parseMergeMethod(result.data.repository.mergeQueue?.configuration?.mergeMethod); + if (mergeMethod) { + this._branchHasMergeQueue.set(branch, mergeMethod); + } + return mergeMethod; + } catch (e) { + Logger.error(`Fetching branch has merge queue failed: ${e}`, GitHubRepository.ID); + } + } + + async getAllPullRequests(page?: number): Promise { + try { + Logger.debug(`Fetch all pull requests - enter`, GitHubRepository.ID); + const { octokit, remote } = await this.ensure(); + const result = await octokit.call(octokit.api.pulls.list, { + owner: remote.owner, + repo: remote.repositoryName, + per_page: PULL_REQUEST_PAGE_SIZE, + page: page || 1, + }); + + const hasMorePages = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + if (!result.data) { + // We really don't expect this to happen, but it seems to (see #574). + // Log a warning and return an empty set. + Logger.warn( + `No result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`, + ); + return { + items: [], + hasMorePages: false, + }; + } + + const pullRequests = result.data + .map(pullRequest => { + if (!pullRequest.head.repo) { + Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); + return null; + } + + return this.createOrUpdatePullRequestModel( + convertRESTPullRequestToRawPullRequest(pullRequest, this), + ); + }) + .filter(item => item !== null) as PullRequestModel[]; + + Logger.debug(`Fetch all pull requests - done`, GitHubRepository.ID); + return { + items: pullRequests, + hasMorePages, + }; + } catch (e) { + Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); + if (e.code === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, + ); + } else { + throw e; + } + } + return undefined; + } + + async getPullRequestForBranch(branch: string, headOwner: string): Promise { + try { + Logger.debug(`Fetch pull requests for branch - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.PullRequestForHead, + variables: { + owner: remote.owner, + name: remote.repositoryName, + headRefName: branch, + }, + }); + Logger.debug(`Fetch pull requests for branch - done`, GitHubRepository.ID); + + if (data?.repository && data.repository.pullRequests.nodes.length > 0) { + const prs = data.repository.pullRequests.nodes.map(node => parseGraphQLPullRequest(node, this)).filter(pr => pr.head?.repo.owner === headOwner); + if (prs.length === 0) { + return undefined; + } + const mostRecentOrOpenPr = prs.find(pr => pr.state.toLowerCase() === 'open') ?? prs[0]; + return this.createOrUpdatePullRequestModel(mostRecentOrOpenPr); + } + } catch (e) { + Logger.error(`Fetching pull requests for branch failed: ${e}`, GitHubRepository.ID); + if (e.code === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, + ); + } + } + return undefined; + } + + async getOrgProjects(): Promise { + Logger.debug(`Fetch org projects - enter`, GitHubRepository.ID); + let { query, remote, schema } = await this.ensure(); + const projects: IProject[] = []; + + try { + const { data } = await query({ + query: schema.GetOrgProjects, + variables: { + owner: remote.owner, + after: null, + } + }); + + if (data && data.organization.projectsV2 && data.organization.projectsV2.nodes) { + data.organization.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + + } catch (e) { + Logger.error(`Unable to fetch org projects: ${e}`, GitHubRepository.ID); + return projects; + } + Logger.debug(`Fetch org projects - done`, GitHubRepository.ID); + + return projects; + } + + async getProjects(): Promise { + try { + Logger.debug(`Fetch projects - enter`, GitHubRepository.ID); + let { query, remote, schema } = await this.ensure(); + if (!schema.GetRepoProjects) { + const additional = await this.ensureAdditionalScopes(); + query = additional.query; + remote = additional.remote; + schema = additional.schema; + } + const { data } = await query({ + query: schema.GetRepoProjects, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch projects - done`, GitHubRepository.ID); + + const projects: IProject[] = []; + if (data && data.repository?.projectsV2 && data.repository.projectsV2.nodes) { + data.repository.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + return projects; + } catch (e) { + Logger.error(`Unable to fetch projects: ${e}`, GitHubRepository.ID); + return; + } + } + + async getMilestones(includeClosed: boolean = false): Promise { + try { + Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const states = ['OPEN']; + if (includeClosed) { + states.push('CLOSED'); + } + const { data } = await query({ + query: schema.GetMilestones, + variables: { + owner: remote.owner, + name: remote.repositoryName, + states: states, + }, + }); + Logger.debug(`Fetch milestones - done`, GitHubRepository.ID); + + const milestones: IMilestone[] = []; + if (data && data.repository?.milestones && data.repository.milestones.nodes) { + data.repository.milestones.nodes.forEach(raw => { + const milestone = parseMilestone(raw); + if (milestone) { + milestones.push(milestone); + } + }); + } + return milestones; + } catch (e) { + Logger.error(`Unable to fetch milestones: ${e}`, GitHubRepository.ID); + return; + } + } + + async getLines(sha: string, file: string, lineStart: number, lineEnd: number): Promise { + Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetFileContent, + variables: { + owner: remote.owner, + name: remote.repositoryName, + expression: `${sha}:${file}` + } + }); + + if (!data.repository?.object.text) { + return undefined; + } + + return data.repository.object.text.split('\n').slice(lineStart - 1, lineEnd).join('\n'); + } + + async getIssuesForUserByMilestone(_page?: number): Promise { + try { + Logger.debug(`Fetch all issues - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetMilestonesWithIssues, + variables: { + owner: remote.owner, + name: remote.repositoryName, + assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, + }, + }); + Logger.debug(`Fetch all issues - done`, GitHubRepository.ID); + + const milestones: { milestone: IMilestone; issues: IssueModel[] }[] = []; + if (data && data.repository?.milestones && data.repository.milestones.nodes) { + data.repository.milestones.nodes.forEach(raw => { + const milestone = parseMilestone(raw); + if (milestone) { + const issues: IssueModel[] = []; + raw.issues.edges.forEach(issue => { + issues.push(new IssueModel(this, this.remote, parseGraphQLIssue(issue.node, this))); + }); + milestones.push({ milestone, issues }); + } + }); + } + return { + items: milestones, + hasMorePages: !!data.repository?.milestones.pageInfo.hasNextPage, + }; + } catch (e) { + Logger.error(`Unable to fetch issues: ${e}`, GitHubRepository.ID); + return; + } + } + + async getIssuesWithoutMilestone(_page?: number): Promise { + try { + Logger.debug(`Fetch issues without milestone- enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.IssuesWithoutMilestone, + variables: { + owner: remote.owner, + name: remote.repositoryName, + assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, + }, + }); + Logger.debug(`Fetch issues without milestone - done`, GitHubRepository.ID); + + const issues: Issue[] = []; + if (data && data.repository?.issues.edges) { + data.repository.issues.edges.forEach(raw => { + if (raw.node.id) { + issues.push(parseGraphQLIssue(raw.node, this)); + } + }); + } + return { + items: issues, + hasMorePages: !!data.repository?.issues.pageInfo.hasNextPage, + }; + } catch (e) { + Logger.error(`Unable to fetch issues without milestone: ${e}`, GitHubRepository.ID); + return; + } + } + + async getIssues(page?: number, queryString?: string): Promise { + try { + Logger.debug(`Fetch issues with query - enter`, GitHubRepository.ID); + const { query, schema } = await this.ensure(); + const { data } = await query({ + query: schema.Issues, + variables: { + query: `${queryString} type:issue`, + }, + }); + Logger.debug(`Fetch issues with query - done`, GitHubRepository.ID); + + const issues: Issue[] = []; + if (data && data.search.edges) { + data.search.edges.forEach(raw => { + if (raw.node.id) { + issues.push(parseGraphQLIssue(raw.node, this)); + } + }); + } + return { + items: issues, + hasMorePages: data.search.pageInfo.hasNextPage, + }; + } catch (e) { + Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); + return; + } + } + + async getMaxIssue(): Promise { + try { + Logger.debug(`Fetch max issue - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.MaxIssue, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch max issue - done`, GitHubRepository.ID); + + if (data?.repository && data.repository.issues.edges.length === 1) { + return data.repository.issues.edges[0].node.number; + } + return; + } catch (e) { + Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); + return; + } + } + + async getViewerPermission(): Promise { + try { + Logger.debug(`Fetch viewer permission - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetViewerPermission, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); + return parseGraphQLViewerPermission(data); + } catch (e) { + Logger.error(`Unable to fetch viewer permission: ${e}`, GitHubRepository.ID); + return ViewerPermission.Unknown; + } + } + + async fork(): Promise { + try { + Logger.debug(`Fork repository`, GitHubRepository.ID); + const { octokit, remote } = await this.ensure(); + const result = await octokit.call(octokit.api.repos.createFork, { + owner: remote.owner, + repo: remote.repositoryName, + }); + return result.data.clone_url; + } catch (e) { + Logger.error(`GitHubRepository> Forking repository failed: ${e}`, GitHubRepository.ID); + return undefined; + } + } + + async getRepositoryForkDetails(): Promise { + try { + Logger.debug(`Fetch repository fork details - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetRepositoryForkDetails, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch repository fork details - done`, GitHubRepository.ID); + return data.repository; + } catch (e) { + Logger.error(`Unable to fetch repository fork details: ${e}`, GitHubRepository.ID); + return; + } + } + + async getAuthenticatedUser(): Promise { + return (await this._credentialStore.getCurrentUser(this.remote.authProviderId)).login; + } + + async getPullRequestsForCategory(categoryQuery: string, page?: number): Promise { + try { + Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, GitHubRepository.ID); + const { octokit, query, schema } = await this.ensure(); + + const user = await this.getAuthenticatedUser(); + // Search api will not try to resolve repo that redirects, so get full name first + const repo = await this.getMetadata(); + const { data, headers } = await octokit.call(octokit.api.search.issuesAndPullRequests, { + q: getPRFetchQuery(repo.full_name, user, categoryQuery), + per_page: PULL_REQUEST_PAGE_SIZE, + page: page || 1, + }); + + const promises: Promise[] = data.items.map(async (item) => { + const prRepo = new Protocol(item.repository_url); + const { data } = await query({ + query: schema.PullRequest, + variables: { + owner: prRepo.owner, + name: prRepo.repositoryName, + number: item.number + } + }); + return data; + }); + + const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; + const pullRequestResponses = await Promise.all(promises); + + const pullRequests = pullRequestResponses + .map(response => { + if (!response.repository?.pullRequest.headRef) { + Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); + return null; + } + + return this.createOrUpdatePullRequestModel( + parseGraphQLPullRequest(response.repository.pullRequest, this), + ); + }) + .filter(item => item !== null) as PullRequestModel[]; + + Logger.debug(`Fetch pull request category ${categoryQuery} - done`, GitHubRepository.ID); + + return { + items: pullRequests, + hasMorePages, + }; + } catch (e) { + Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); + if (e.code === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull requests for remote ${this.remote.remoteName}, please check if the url ${this.remote.url} is valid.`, + ); + } else { + throw e; + } + } + return undefined; + } + + createOrUpdatePullRequestModel(pullRequest: PullRequest): PullRequestModel { + let model = this._pullRequestModels.get(pullRequest.number); + if (model) { + model.update(pullRequest); + } else { + model = new PullRequestModel(this._credentialStore, this._telemetry, this, this.remote, pullRequest); + model.onDidInvalidate(() => this.getPullRequest(pullRequest.number)); + this._pullRequestModels.set(pullRequest.number, model); + this._onDidAddPullRequest.fire(model); + } + + return model; + } + + async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { + try { + Logger.debug(`Create pull request - enter`, GitHubRepository.ID); + const metadata = await this.getMetadata(); + const { mutate, schema } = await this.ensure(); + + const { data } = await mutate({ + mutation: schema.CreatePullRequest, + variables: { + input: { + repositoryId: metadata.node_id, + baseRefName: params.base, + headRefName: params.head, + title: params.title, + body: params.body, + draft: params.draft + } + } + }); + Logger.debug(`Create pull request - done`, GitHubRepository.ID); + if (!data) { + throw new Error('Failed to create pull request.'); + } + return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); + } catch (e) { + Logger.error(`Unable to create PR: ${e}`, GitHubRepository.ID); + throw e; + } + } + + async getPullRequest(id: number): Promise { + try { + Logger.debug(`Fetch pull request ${id} - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const { data } = await query({ + query: schema.PullRequest, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: id, + }, + }, true); + if (data.repository === null) { + Logger.error('Unexpected null repository when getting PR', GitHubRepository.ID); + return; + } + + Logger.debug(`Fetch pull request ${id} - done`, GitHubRepository.ID); + return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.repository.pullRequest, this)); + } catch (e) { + Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); + return; + } + } + + async getIssue(id: number, withComments: boolean = false): Promise { + try { + Logger.debug(`Fetch issue ${id} - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const { data } = await query({ + query: withComments ? schema.IssueWithComments : schema.Issue, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: id, + }, + }, true); // Don't retry on SAML errors as it's too disruptive for this query. + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue', GitHubRepository.ID); + return undefined; + } + Logger.debug(`Fetch issue ${id} - done`, GitHubRepository.ID); + + return new IssueModel(this, remote, parseGraphQLIssue(data.repository.pullRequest, this)); + } catch (e) { + Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); + return; + } + } + + async hasBranch(branchName: string): Promise { + Logger.appendLine(`Fetch branch ${branchName} - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const { data } = await query({ + query: schema.GetBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + qualifiedName: `refs/heads/${branchName}`, + } + }); + Logger.appendLine(`Fetch branch ${branchName} - done: ${data.repository?.ref !== null}`, GitHubRepository.ID); + return data.repository?.ref !== null; + } + + async listBranches(owner: string, repositoryName: string): Promise { + const { query, remote, schema } = await this.ensure(); + Logger.debug(`List branches for ${owner}/${repositoryName} - enter`, GitHubRepository.ID); + + let after: string | null = null; + let hasNextPage = false; + const branches: string[] = []; + const startingTime = new Date().getTime(); + + do { + try { + const { data } = await query({ + query: schema.ListBranches, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }); + + branches.push(...data.repository.refs.nodes.map(node => node.name)); + if (new Date().getTime() - startingTime > 5000) { + Logger.warn('List branches timeout hit.', GitHubRepository.ID); + break; + } + hasNextPage = data.repository.refs.pageInfo.hasNextPage; + after = data.repository.refs.pageInfo.endCursor; + } catch (e) { + Logger.debug(`List branches for ${owner}/${repositoryName} failed`, GitHubRepository.ID); + throw e; + } + } while (hasNextPage); + + Logger.debug(`List branches for ${owner}/${repositoryName} - done`, GitHubRepository.ID); + return branches; + } + + async deleteBranch(pullRequestModel: PullRequestModel): Promise { + const { octokit } = await this.ensure(); + + if (!pullRequestModel.validatePullRequestModel('Unable to delete branch')) { + return; + } + + try { + await octokit.call(octokit.api.git.deleteRef, { + owner: pullRequestModel.head.repositoryCloneUrl.owner, + repo: pullRequestModel.head.repositoryCloneUrl.repositoryName, + ref: `heads/${pullRequestModel.head.ref}`, + }); + } catch (e) { + Logger.error(`Unable to delete branch: ${e}`, GitHubRepository.ID); + return; + } + } + + async getMentionableUsers(): Promise { + Logger.debug(`Fetch mentionable users - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + let after: string | null = null; + let hasNextPage = false; + const ret: IAccount[] = []; + + do { + try { + const result: { data: MentionableUsersResponse } = await query({ + query: schema.GetMentionableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }); + + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting mentionable users', GitHubRepository.ID); + return []; + } + + ret.push( + ...result.data.repository.mentionableUsers.nodes.map(node => { + return { + login: node.login, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + email: node.email, + id: node.id + }; + }), + ); + + hasNextPage = result.data.repository.mentionableUsers.pageInfo.hasNextPage; + after = result.data.repository.mentionableUsers.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch mentionable users: ${e}`, GitHubRepository.ID); + return ret; + } + } while (hasNextPage); + + return ret; + } + + async getAssignableUsers(): Promise { + Logger.debug(`Fetch assignable users - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + let after: string | null = null; + let hasNextPage = false; + const ret: IAccount[] = []; + + do { + try { + const result: { data: AssignableUsersResponse } = await query({ + query: schema.GetAssignableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }, true); // we ignore SAML errors here because this query can happen at startup + + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting assignable users', GitHubRepository.ID); + return []; + } + + ret.push( + ...result.data.repository.assignableUsers.nodes.map(node => { + return { + login: node.login, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + email: node.email, + id: node.id + }; + }), + ); + + hasNextPage = result.data.repository.assignableUsers.pageInfo.hasNextPage; + after = result.data.repository.assignableUsers.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch assignable users: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub user features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return ret; + } + } while (hasNextPage); + + return ret; + } + + async getOrgTeamsCount(): Promise { + Logger.debug(`Fetch Teams Count - enter`, GitHubRepository.ID); + if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) { + return 0; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + try { + const result: { data: OrganizationTeamsCountResponse } = await query({ + query: schema.GetOrganizationTeamsCount, + variables: { + login: remote.owner + }, + }); + return result.data.organization.teams.totalCount; + } catch (e) { + Logger.debug(`Unable to fetch teams Count: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return 0; + } + } + + async getOrgTeams(refreshKind: TeamReviewerRefreshKind): Promise<(ITeam & { repositoryNames: string[] })[]> { + Logger.debug(`Fetch Teams - enter`, GitHubRepository.ID); + if ((refreshKind === TeamReviewerRefreshKind.None) || (refreshKind === TeamReviewerRefreshKind.Try && !this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId))) { + Logger.debug(`Fetch Teams - exit without fetching teams`, GitHubRepository.ID); + return []; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + let after: string | null = null; + let hasNextPage = false; + const orgTeams: (ITeam & { repositoryNames: string[] })[] = []; + + do { + try { + const result: { data: OrganizationTeamsResponse } = await query({ + query: schema.GetOrganizationTeams, + variables: { + login: remote.owner, + after: after, + repoName: remote.repositoryName, + }, + }); + + result.data.organization.teams.nodes.forEach(node => { + const team: ITeam = { + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + slug: node.slug, + id: node.id, + org: remote.owner + }; + orgTeams.push({ ...team, repositoryNames: node.repositories.nodes.map(repo => repo.name) }); + }); + + hasNextPage = result.data.organization.teams.pageInfo.hasNextPage; + after = result.data.organization.teams.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch teams: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return orgTeams; + } + } while (hasNextPage); + + Logger.debug(`Fetch Teams - exit`, GitHubRepository.ID); + return orgTeams; + } + + async getPullRequestParticipants(pullRequestNumber: number): Promise { + Logger.debug(`Fetch participants from a Pull Request`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const ret: IAccount[] = []; + + try { + const result: { data: PullRequestParticipantsResponse } = await query({ + query: schema.GetParticipants, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: pullRequestNumber, + first: 18 + }, + }); + if (result.data.repository === null) { + Logger.error('Unexpected null repository when fetching participants', GitHubRepository.ID); + return []; + } + + ret.push( + ...result.data.repository.pullRequest.participants.nodes.map(node => { + return { + login: node.login, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + email: node.email, + id: node.id + }; + }), + ); + } catch (e) { + Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub user features will not work. ${e.graphQLErrors[0].message}`, + ); + } + } + + return ret; + } + + /** + * Compare across commits. + * @param base The base branch. Must be a branch name. If comparing across repositories, use the format :branch. + * @param head The head branch. Must be a branch name. If comparing across repositories, use the format :branch. + */ + public async compareCommits(base: string, head: string): Promise { + Logger.debug('Compare commits - enter', GitHubRepository.ID); + try { + const { remote, octokit } = await this.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base, + head, + }); + Logger.debug('Compare commits - done', GitHubRepository.ID); + return data; + } catch (e) { + Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, GitHubRepository.ID); + } + } + + isCurrentUser(login: string): Promise { + return this._credentialStore.isCurrentUser(login); + } + + /** + * Get the status checks of the pull request, those for the last commit. + * + * This method should go in PullRequestModel, but because of the status checks bug we want to track `_useFallbackChecks` at a repo level. + */ + private _useFallbackChecks: boolean = false; + async getStatusChecks(number: number): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + const { query, remote, schema } = await this.ensure(); + const captureUseFallbackChecks = this._useFallbackChecks; + let result: ApolloQueryResult; + try { + result = await query({ + query: captureUseFallbackChecks ? schema.GetChecksWithoutSuite : schema.GetChecks, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: number, + }, + }, true); // There's an issue with the GetChecks that can result in SAML errors. + } catch (e) { + if (e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + // There seems to be an issue with fetching status checks if you haven't SAML'd with every org you have + // The issue is specifically with the CheckSuite property. Make the query again, but without that property. + if (!captureUseFallbackChecks) { + this._useFallbackChecks = true; + return this.getStatusChecks(number); + } + } + throw e; + } + + if ((result.data.repository === null) || (result.data.repository.pullRequest.commits.nodes === undefined) || (result.data.repository.pullRequest.commits.nodes.length === 0)) { + Logger.error(`Unable to fetch PR checks: ${result.errors?.map(error => error.message).join(', ')}`, GitHubRepository.ID); + return [null, null]; + } + + // We always fetch the status checks for only the last commit, so there should only be one node present + const statusCheckRollup = result.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup; + + const checks: PullRequestChecks = !statusCheckRollup + ? { + state: CheckState.Success, + statuses: [] + } + : { + state: this.mapStateAsCheckState(statusCheckRollup.state), + statuses: statusCheckRollup.contexts.nodes.map(context => { + if (isCheckRun(context)) { + return { + id: context.id, + url: context.checkSuite?.app?.url, + avatarUrl: + context.checkSuite?.app?.logoUrl && + getAvatarWithEnterpriseFallback( + context.checkSuite.app.logoUrl, + undefined, + this.remote.isEnterprise, + ), + state: this.mapStateAsCheckState(context.conclusion), + description: context.title, + context: context.name, + targetUrl: context.detailsUrl, + isRequired: context.isRequired, + }; + } else { + return { + id: context.id, + url: context.targetUrl ?? undefined, + avatarUrl: context.avatarUrl + ? getAvatarWithEnterpriseFallback(context.avatarUrl, undefined, this.remote.isEnterprise) + : undefined, + state: this.mapStateAsCheckState(context.state), + description: context.description, + context: context.context, + targetUrl: context.targetUrl, + isRequired: context.isRequired, + }; + } + }), + }; + + let reviewRequirement: PullRequestReviewRequirement | null = null; + const rule = result.data.repository.pullRequest.baseRef.refUpdateRule; + if (rule) { + const prUrl = result.data.repository.pullRequest.url; + + for (const context of rule.requiredStatusCheckContexts || []) { + if (!checks.statuses.some(status => status.context === context)) { + checks.state = CheckState.Pending; + checks.statuses.push({ + id: '', + url: undefined, + avatarUrl: undefined, + state: CheckState.Pending, + description: vscode.l10n.t('Waiting for status to be reported'), + context: context, + targetUrl: prUrl, + isRequired: true + }); + } + } + + const requiredApprovingReviews = rule.requiredApprovingReviewCount ?? 0; + const approvingReviews = result.data.repository.pullRequest.latestReviews.nodes.filter( + review => review.authorCanPushToRepository && review.state === 'APPROVED', + ); + const requestedChanges = result.data.repository.pullRequest.reviewsRequestingChanges.nodes.filter( + review => review.authorCanPushToRepository + ); + let state: CheckState = CheckState.Success; + if (approvingReviews.length < requiredApprovingReviews) { + state = CheckState.Failure; + + if (requestedChanges.length) { + state = CheckState.Pending; + } + } + if (requiredApprovingReviews > 0) { + reviewRequirement = { + count: requiredApprovingReviews, + approvals: approvingReviews.map(review => review.author.login), + requestedChanges: requestedChanges.map(review => review.author.login), + state: state + }; + } + } + + return [checks.statuses.length ? checks : null, reviewRequirement]; + } + + mapStateAsCheckState(state: string | null | undefined): CheckState { + switch (state) { + case 'EXPECTED': + case 'PENDING': + case 'ACTION_REQUIRED': + case 'STALE': + return CheckState.Pending; + case 'ERROR': + case 'FAILURE': + case 'TIMED_OUT': + case 'STARTUP_FAILURE': + return CheckState.Failure; + case 'SUCCESS': + return CheckState.Success; + case 'NEUTRAL': + case 'SKIPPED': + return CheckState.Neutral; + } + + return CheckState.Unknown; + } +} diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 1bf331f743..61f16e3aee 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -1,921 +1,922 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { DiffSide, SubjectType, ViewedState } from '../common/comment'; -import { ForkDetails } from './githubRepository'; - -interface PageInfo { - hasNextPage: boolean; - endCursor: string; -} - -export interface MergedEvent { - __typename: string; - id: string; - actor: { - login: string; - avatarUrl: string; - url: string; - }; - createdAt: string; - mergeRef: { - name: string; - }; - commit: { - oid: string; - commitUrl: string; - }; - url: string; -} - -export interface HeadRefDeletedEvent { - __typename: string; - id: string; - actor: { - login: string; - avatarUrl: string; - url: string; - }; - createdAt: string; - headRefName: string; -} - -export interface AbbreviatedIssueComment { - author: { - login: string; - avatarUrl: string; - url: string; - email?: string; - id: string; - }; - body: string; - databaseId: number; -} - -export interface IssueComment extends AbbreviatedIssueComment { - __typename: string; - authorAssociation: string; - id: string; - url: string; - bodyHTML: string; - updatedAt: string; - createdAt: string; - viewerCanUpdate: boolean; - viewerCanReact: boolean; - viewerCanDelete: boolean; -} - -export interface ReactionGroup { - content: string; - viewerHasReacted: boolean; - reactors: { - nodes: { - login: string; - }[] - totalCount: number; - }; -} - -export interface Account { - login: string; - avatarUrl: string; - name: string; - url: string; - email: string; - id: string; -} - -interface Team { - avatarUrl: string; - name: string; - url: string; - repositories: { - nodes: { - name: string - }[]; - }; - slug: string; - id: string; -} - -export interface ReviewComment { - __typename: string; - id: string; - databaseId: number; - url: string; - author?: { - login: string; - avatarUrl: string; - url: string; - id: string; - }; - path: string; - originalPosition: number; - body: string; - bodyHTML: string; - diffHunk: string; - position: number; - state: string; - pullRequestReview: { - databaseId: number; - }; - commit: { - oid: string; - }; - originalCommit: { - oid: string; - }; - createdAt: string; - replyTo: { - databaseId: number; - }; - reactionGroups: ReactionGroup[]; - viewerCanUpdate: boolean; - viewerCanDelete: boolean; -} - -export interface Commit { - __typename: string; - id: string; - commit: { - author: { - user: { - login: string; - avatarUrl: string; - url: string; - id: string; - }; - }; - committer: { - avatarUrl: string; - name: string; - }; - oid: string; - message: string; - authoredDate: Date; - }; - - url: string; -} - -export interface AssignedEvent { - __typename: string; - id: number; - actor: { - login: string; - avatarUrl: string; - url: string; - }; - user: { - login: string; - avatarUrl: string; - url: string; - id: string; - }; -} - -export interface MergeQueueEntry { - position: number, - state: MergeQueueState; - mergeQueue: { - url: string; - } -} - -export interface Review { - __typename: string; - id: string; - databaseId: number; - authorAssociation: string; - url: string; - author: { - login: string; - avatarUrl: string; - url: string; - id: string; - }; - state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; - body: string; - bodyHTML?: string; - submittedAt: string; - updatedAt: string; - createdAt: string; -} - -export interface ReviewThread { - id: string; - isResolved: boolean; - viewerCanResolve: boolean; - viewerCanUnresolve: boolean; - path: string; - diffSide: DiffSide; - startLine: number | null; - line: number; - originalStartLine: number | null; - originalLine: number; - isOutdated: boolean; - subjectType?: SubjectType; - comments: { - nodes: ReviewComment[]; - edges: [{ - node: { - pullRequestReview: { - databaseId: number - } - } - }] - }; -} - -export interface TimelineEventsResponse { - repository: { - pullRequest: { - timelineItems: { - nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[]; - }; - }; - } | null; - rateLimit: RateLimit; -} - -export interface LatestReviewCommitResponse { - repository: { - pullRequest: { - viewerLatestReview: { - commit: { - oid: string; - } - }; - }; - } | null; -} - -export interface PendingReviewIdResponse { - node: { - reviews: { - nodes: Review[]; - }; - }; - rateLimit: RateLimit; -} - -export interface GetReviewRequestsResponse { - repository: { - pullRequest: { - reviewRequests: { - nodes: { - requestedReviewer: { - // Shared properties between accounts and teams - avatarUrl: string; - url: string; - name: string; - // Account properties - login?: string; - email?: string; - // Team properties - slug?: string; - id: string; - } | null; - }[]; - }; - }; - } | null; -} - -export interface PullRequestState { - repository: { - pullRequest: { - title: string; - number: number; - state: 'OPEN' | 'CLOSED' | 'MERGED'; - }; - } | null; -} - -export interface PullRequestCommentsResponse { - repository: { - pullRequest: { - reviewThreads: { - nodes: ReviewThread[]; - pageInfo: PageInfo; - }; - }; - } | null; -} - -export interface MentionableUsersResponse { - repository: { - mentionableUsers: { - nodes: Account[]; - pageInfo: PageInfo; - }; - } | null; - rateLimit: RateLimit; -} - -export interface AssignableUsersResponse { - repository: { - assignableUsers: { - nodes: Account[]; - pageInfo: PageInfo; - }; - } | null; - rateLimit: RateLimit; -} - -export interface OrganizationTeamsCountResponse { - organization: { - teams: { - totalCount: number; - }; - }; -} - -export interface OrganizationTeamsResponse { - organization: { - teams: { - nodes: Team[]; - totalCount: number; - pageInfo: PageInfo; - }; - }; - rateLimit: RateLimit; -} - -export interface PullRequestParticipantsResponse { - repository: { - pullRequest: { - participants: { - nodes: Account[]; - }; - }; - } | null; -} - -export interface CreatePullRequestResponse { - createPullRequest: { - pullRequest: PullRequest - } -} - -export interface AddReviewThreadResponse { - addPullRequestReviewThread: { - thread: ReviewThread; - } -} - -export interface AddCommentResponse { - addPullRequestReviewComment: { - comment: ReviewComment; - }; -} - -export interface AddIssueCommentResponse { - addComment: { - commentEdge: { - node: IssueComment; - }; - }; -} - -export interface EditCommentResponse { - updatePullRequestReviewComment: { - pullRequestReviewComment: ReviewComment; - }; -} - -export interface EditIssueCommentResponse { - updateIssueComment: { - issueComment: IssueComment; - }; -} - -export interface MarkPullRequestReadyForReviewResponse { - markPullRequestReadyForReview: { - pullRequest: { - isDraft: boolean; - }; - }; -} - -export interface MergeQueueForBranchResponse { - repository: { - mergeQueue?: { - configuration?: { - mergeMethod: MergeMethod; - } - } - } -} - -export interface DequeuePullRequestResponse { - mergeQueueEntry: MergeQueueEntry; -} - -export interface EnqueuePullRequestResponse { - enqueuePullRequest: { - mergeQueueEntry: MergeQueueEntry; - } -} - -export interface SubmittedReview extends Review { - comments: { - nodes: ReviewComment[]; - }; -} - -export interface SubmitReviewResponse { - submitPullRequestReview: { - pullRequestReview: SubmittedReview; - }; -} - -export interface DeleteReviewResponse { - deletePullRequestReview: { - pullRequestReview: { - databaseId: number; - comments: { - nodes: ReviewComment[]; - }; - }; - }; -} - -export interface AddReactionResponse { - addReaction: { - reaction: { - content: string; - }; - subject: { - reactionGroups: ReactionGroup[]; - }; - }; -} - -export interface DeleteReactionResponse { - removeReaction: { - reaction: { - content: string; - }; - subject: { - reactionGroups: ReactionGroup[]; - }; - }; -} - -export interface UpdatePullRequestResponse { - updatePullRequest: { - pullRequest: { - body: string; - bodyHTML: string; - title: string; - titleHTML: string; - }; - }; -} - -export interface AddPullRequestToProjectResponse { - addProjectV2ItemById: { - item: { - id: string; - }; - }; -} - -export interface GetBranchResponse { - repository: { - ref: { - target: { - oid: string; - } - } - } | null; -} - -export interface ListBranchesResponse { - repository: { - refs: { - nodes: { - name: string; - }[]; - pageInfo: PageInfo; - }; - } | null; -} - -export interface RefRepository { - isInOrganization: boolean; - owner: { - login: string; - }; - url: string; -} - -export interface BaseRefRepository extends RefRepository { - squashMergeCommitTitle?: DefaultCommitTitle; - squashMergeCommitMessage?: DefaultCommitMessage; - mergeCommitMessage?: DefaultCommitMessage; - mergeCommitTitle?: DefaultCommitTitle; -} - -export interface Ref { - name: string; - repository: RefRepository; - target: { - oid: string; - }; -} - -export interface SuggestedReviewerResponse { - isAuthor: boolean; - isCommenter: boolean; - reviewer: { - login: string; - avatarUrl: string; - name: string; - url: string; - id: string; - }; -} - -export type MergeMethod = 'MERGE' | 'REBASE' | 'SQUASH'; -export type MergeQueueState = 'AWAITING_CHECKS' | 'LOCKED' | 'MERGEABLE' | 'QUEUED' | 'UNMERGEABLE'; - -export interface PullRequest { - id: string; - databaseId: number; - number: number; - url: string; - state: 'OPEN' | 'CLOSED' | 'MERGED'; - body: string; - bodyHTML: string; - title: string; - titleHTML: string; - assignees?: { - nodes: { - login: string; - url: string; - email: string; - avatarUrl: string; - id: string; - }[]; - }; - author: { - login: string; - url: string; - avatarUrl: string; - id: string; - }; - commits: { - nodes: { - commit: { - message: string; - }; - }[]; - }; - comments?: { - nodes: AbbreviatedIssueComment[]; - }; - createdAt: string; - updatedAt: string; - headRef?: Ref; - headRefName: string; - headRefOid: string; - headRepository?: RefRepository; - baseRef?: Ref; - baseRefName: string; - baseRefOid: string; - baseRepository: BaseRefRepository; - labels: { - nodes: { - name: string; - color: string; - }[]; - }; - merged: boolean; - mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - mergeQueueEntry?: MergeQueueEntry | null; - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; - autoMergeRequest?: { - mergeMethod: MergeMethod; - }; - viewerCanEnableAutoMerge: boolean; - viewerCanDisableAutoMerge: boolean; - isDraft?: boolean; - suggestedReviewers: SuggestedReviewerResponse[]; - projectItems?: { - nodes: { - project: { - id: string; - title: string; - }, - id: string - }[]; - }; - milestone?: { - title: string; - dueOn?: string; - id: string; - createdAt: string; - number: number; - }; - repository?: { - name: string; - owner: { - login: string; - }; - url: string; - }; -} - -export enum DefaultCommitTitle { - prTitle = 'PR_TITLE', - commitOrPrTitle = 'COMMIT_OR_PR_TITLE', - mergeMessage = 'MERGE_MESSAGE' -} - -export enum DefaultCommitMessage { - prBody = 'PR_BODY', - commitMessages = 'COMMIT_MESSAGES', - blank = 'BLANK', - prTitle = 'PR_TITLE' -} - -export interface PullRequestResponse { - repository: { - pullRequest: PullRequest; - } | null; - rateLimit: RateLimit; -} - -export interface PullRequestMergabilityResponse { - repository: { - pullRequest: { - mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; - }; - } | null; - rateLimit: RateLimit; -} - -export interface IssuesSearchResponse { - search: { - issueCount: number; - pageInfo: PageInfo; - edges: { - node: PullRequest; - }[]; - }; - rateLimit: RateLimit; -} - -export interface RepoProjectsResponse { - repository: { - projectsV2: { - nodes: { - title: string; - id: string; - }[]; - } - } | null; -} - -export interface OrgProjectsResponse { - organization: { - projectsV2: { - nodes: { - title: string; - id: string; - }[]; - pageInfo: PageInfo; - } - } -} - -export interface MilestoneIssuesResponse { - repository: { - milestones: { - nodes: { - dueOn: string; - createdAt: string; - title: string; - id: string; - number: number - issues: { - edges: { - node: PullRequest; - }[]; - }; - }[]; - pageInfo: PageInfo; - }; - } | null; -} - -export interface IssuesResponse { - repository: { - issues: { - edges: { - node: PullRequest; - }[]; - pageInfo: PageInfo; - }; - } | null; -} - -export interface PullRequestsResponse { - repository: { - pullRequests: { - nodes: PullRequest[] - } - } | null; -} - -export interface MaxIssueResponse { - repository: { - issues: { - edges: { - node: { - number: number; - }; - }[]; - }; - } | null; -} - -export interface ViewerPermissionResponse { - repository: { - viewerPermission: string; - } | null; -} - -export interface ForkDetailsResponse { - repository: ForkDetails; -} - -export interface QueryWithRateLimit { - rateLimit: RateLimit; -} -export interface RateLimit { - limit: number; - cost: number; - remaining: number; - resetAt: string; -} - -export interface ContributionsCollection { - commitContributionsByRepository: { - contributions: { - nodes: { - occurredAt: string; - }[]; - }; - repository: { - nameWithOwner: string; - }; - }[]; -} - -export interface UserResponse { - user: { - login: string; - avatarUrl?: string; - bio?: string; - company?: string; - location?: string; - name: string; - contributionsCollection: ContributionsCollection; - url: string; - id: string; - }; -} - -export interface FileContentResponse { - repository: { - object: { - text: string | undefined; - } - } | null; -} - -export interface StartReviewResponse { - addPullRequestReview: { - pullRequestReview: { - id: string; - }; - }; -} - -export interface StatusContext { - __typename: string; - id: string; - state: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; - description: string | null; - context: string; - targetUrl: string | null; - avatarUrl: string | null; - isRequired: boolean; -} - -export interface CheckRun { - __typename: string; - id: string; - conclusion: - | 'ACTION_REQUIRED' - | 'CANCELLED' - | 'FAILURE' - | 'NEUTRAL' - | 'SKIPPED' - | 'STALE' - | 'SUCCESS' - | 'TIMED_OUT' - | null; - name: string; - title: string | null; - detailsUrl: string | null; - checkSuite?: { - app: { - logoUrl: string; - url: string; - } | null; - }; - isRequired: boolean; -} - -export function isCheckRun(x: CheckRun | StatusContext): x is CheckRun { - return x.__typename === 'CheckRun'; -} - -export interface ChecksReviewNode { - authorAssociation: 'MEMBER' | 'OWNER' | 'MANNEQUIN' | 'COLLABORATOR' | 'CONTRIBUTOR' | 'FIRST_TIME_CONTRIBUTOR' | 'FIRST_TIMER' | 'NONE'; - authorCanPushToRepository: boolean - state: 'PENDING' | 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'; - author: { - login: string; - } -} - -export interface GetChecksResponse { - repository: { - pullRequest: { - url: string; - latestReviews: { - nodes: ChecksReviewNode[]; - }; - reviewsRequestingChanges: { - nodes: ChecksReviewNode[]; - }; - baseRef: { - refUpdateRule: { - requiredApprovingReviewCount: number | null; - requiredStatusCheckContexts: string[] | null; - requiresCodeOwnerReviews: boolean; - viewerCanPush: boolean; - } | null; - }; - commits: { - nodes: { - commit: { - statusCheckRollup?: { - state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; - contexts: { - nodes: (StatusContext | CheckRun)[]; - }; - }; - }; - }[] | undefined; - }; - }; - } | null; -} - -export interface ResolveReviewThreadResponse { - resolveReviewThread: { - thread: ReviewThread; - } -} - -export interface UnresolveReviewThreadResponse { - unresolveReviewThread: { - thread: ReviewThread; - } -} - -export interface PullRequestFilesResponse { - repository: { - pullRequest: { - files: { - nodes: { - path: string; - viewerViewedState: ViewedState - }[] - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; - } - } - } | null; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { DiffSide, SubjectType, ViewedState } from '../common/comment'; +import { ForkDetails } from './githubRepository'; + +interface PageInfo { + hasNextPage: boolean; + endCursor: string; +} + +export interface MergedEvent { + __typename: string; + id: string; + actor: { + login: string; + avatarUrl: string; + url: string; + }; + createdAt: string; + mergeRef: { + name: string; + }; + commit: { + oid: string; + commitUrl: string; + }; + url: string; +} + +export interface HeadRefDeletedEvent { + __typename: string; + id: string; + actor: { + login: string; + avatarUrl: string; + url: string; + }; + createdAt: string; + headRefName: string; +} + +export interface AbbreviatedIssueComment { + author: { + login: string; + avatarUrl: string; + url: string; + email?: string; + id: string; + }; + body: string; + databaseId: number; +} + +export interface IssueComment extends AbbreviatedIssueComment { + __typename: string; + authorAssociation: string; + id: string; + url: string; + bodyHTML: string; + updatedAt: string; + createdAt: string; + viewerCanUpdate: boolean; + viewerCanReact: boolean; + viewerCanDelete: boolean; +} + +export interface ReactionGroup { + content: string; + viewerHasReacted: boolean; + reactors: { + nodes: { + login: string; + }[] + totalCount: number; + }; +} + +export interface Account { + login: string; + avatarUrl: string; + name: string; + url: string; + email: string; + id: string; +} + +interface Team { + avatarUrl: string; + name: string; + url: string; + repositories: { + nodes: { + name: string + }[]; + }; + slug: string; + id: string; +} + +export interface ReviewComment { + __typename: string; + id: string; + databaseId: number; + url: string; + author?: { + login: string; + avatarUrl: string; + url: string; + id: string; + }; + path: string; + originalPosition: number; + body: string; + bodyHTML: string; + diffHunk: string; + position: number; + state: string; + pullRequestReview: { + databaseId: number; + }; + commit: { + oid: string; + }; + originalCommit: { + oid: string; + }; + createdAt: string; + replyTo: { + databaseId: number; + }; + reactionGroups: ReactionGroup[]; + viewerCanUpdate: boolean; + viewerCanDelete: boolean; +} + +export interface Commit { + __typename: string; + id: string; + commit: { + author: { + user: { + login: string; + avatarUrl: string; + url: string; + id: string; + }; + }; + committer: { + avatarUrl: string; + name: string; + }; + oid: string; + message: string; + authoredDate: Date; + }; + + url: string; +} + +export interface AssignedEvent { + __typename: string; + id: number; + actor: { + login: string; + avatarUrl: string; + url: string; + }; + user: { + login: string; + avatarUrl: string; + url: string; + id: string; + }; +} + +export interface MergeQueueEntry { + position: number, + state: MergeQueueState; + mergeQueue: { + url: string; + } +} + +export interface Review { + __typename: string; + id: string; + databaseId: number; + authorAssociation: string; + url: string; + author: { + login: string; + avatarUrl: string; + url: string; + id: string; + }; + state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; + body: string; + bodyHTML?: string; + submittedAt: string; + updatedAt: string; + createdAt: string; +} + +export interface ReviewThread { + id: string; + isResolved: boolean; + viewerCanResolve: boolean; + viewerCanUnresolve: boolean; + path: string; + diffSide: DiffSide; + startLine: number | null; + line: number; + originalStartLine: number | null; + originalLine: number; + isOutdated: boolean; + subjectType?: SubjectType; + comments: { + nodes: ReviewComment[]; + edges: [{ + node: { + pullRequestReview: { + databaseId: number + } + } + }] + }; +} + +export interface TimelineEventsResponse { + repository: { + pullRequest: { + timelineItems: { + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[]; + }; + }; + } | null; + rateLimit: RateLimit; +} + +export interface LatestReviewCommitResponse { + repository: { + pullRequest: { + viewerLatestReview: { + commit: { + oid: string; + } + }; + }; + } | null; +} + +export interface PendingReviewIdResponse { + node: { + reviews: { + nodes: Review[]; + }; + }; + rateLimit: RateLimit; +} + +export interface GetReviewRequestsResponse { + repository: { + pullRequest: { + reviewRequests: { + nodes: { + requestedReviewer: { + // Shared properties between accounts and teams + avatarUrl: string; + url: string; + name: string; + // Account properties + login?: string; + email?: string; + // Team properties + slug?: string; + id: string; + } | null; + }[]; + }; + }; + } | null; +} + +export interface PullRequestState { + repository: { + pullRequest: { + title: string; + number: number; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + }; + } | null; +} + +export interface PullRequestCommentsResponse { + repository: { + pullRequest: { + reviewThreads: { + nodes: ReviewThread[]; + pageInfo: PageInfo; + }; + }; + } | null; +} + +export interface MentionableUsersResponse { + repository: { + mentionableUsers: { + nodes: Account[]; + pageInfo: PageInfo; + }; + } | null; + rateLimit: RateLimit; +} + +export interface AssignableUsersResponse { + repository: { + assignableUsers: { + nodes: Account[]; + pageInfo: PageInfo; + }; + } | null; + rateLimit: RateLimit; +} + +export interface OrganizationTeamsCountResponse { + organization: { + teams: { + totalCount: number; + }; + }; +} + +export interface OrganizationTeamsResponse { + organization: { + teams: { + nodes: Team[]; + totalCount: number; + pageInfo: PageInfo; + }; + }; + rateLimit: RateLimit; +} + +export interface PullRequestParticipantsResponse { + repository: { + pullRequest: { + participants: { + nodes: Account[]; + }; + }; + } | null; +} + +export interface CreatePullRequestResponse { + createPullRequest: { + pullRequest: PullRequest + } +} + +export interface AddReviewThreadResponse { + addPullRequestReviewThread: { + thread: ReviewThread; + } +} + +export interface AddCommentResponse { + addPullRequestReviewComment: { + comment: ReviewComment; + }; +} + +export interface AddIssueCommentResponse { + addComment: { + commentEdge: { + node: IssueComment; + }; + }; +} + +export interface EditCommentResponse { + updatePullRequestReviewComment: { + pullRequestReviewComment: ReviewComment; + }; +} + +export interface EditIssueCommentResponse { + updateIssueComment: { + issueComment: IssueComment; + }; +} + +export interface MarkPullRequestReadyForReviewResponse { + markPullRequestReadyForReview: { + pullRequest: { + isDraft: boolean; + }; + }; +} + +export interface MergeQueueForBranchResponse { + repository: { + mergeQueue?: { + configuration?: { + mergeMethod: MergeMethod; + } + } + } +} + +export interface DequeuePullRequestResponse { + mergeQueueEntry: MergeQueueEntry; +} + +export interface EnqueuePullRequestResponse { + enqueuePullRequest: { + mergeQueueEntry: MergeQueueEntry; + } +} + +export interface SubmittedReview extends Review { + comments: { + nodes: ReviewComment[]; + }; +} + +export interface SubmitReviewResponse { + submitPullRequestReview: { + pullRequestReview: SubmittedReview; + }; +} + +export interface DeleteReviewResponse { + deletePullRequestReview: { + pullRequestReview: { + databaseId: number; + comments: { + nodes: ReviewComment[]; + }; + }; + }; +} + +export interface AddReactionResponse { + addReaction: { + reaction: { + content: string; + }; + subject: { + reactionGroups: ReactionGroup[]; + }; + }; +} + +export interface DeleteReactionResponse { + removeReaction: { + reaction: { + content: string; + }; + subject: { + reactionGroups: ReactionGroup[]; + }; + }; +} + +export interface UpdatePullRequestResponse { + updatePullRequest: { + pullRequest: { + body: string; + bodyHTML: string; + title: string; + titleHTML: string; + }; + }; +} + +export interface AddPullRequestToProjectResponse { + addProjectV2ItemById: { + item: { + id: string; + }; + }; +} + +export interface GetBranchResponse { + repository: { + ref: { + target: { + oid: string; + } + } + } | null; +} + +export interface ListBranchesResponse { + repository: { + refs: { + nodes: { + name: string; + }[]; + pageInfo: PageInfo; + }; + } | null; +} + +export interface RefRepository { + isInOrganization: boolean; + owner: { + login: string; + }; + url: string; +} + +export interface BaseRefRepository extends RefRepository { + squashMergeCommitTitle?: DefaultCommitTitle; + squashMergeCommitMessage?: DefaultCommitMessage; + mergeCommitMessage?: DefaultCommitMessage; + mergeCommitTitle?: DefaultCommitTitle; +} + +export interface Ref { + name: string; + repository: RefRepository; + target: { + oid: string; + }; +} + +export interface SuggestedReviewerResponse { + isAuthor: boolean; + isCommenter: boolean; + reviewer: { + login: string; + avatarUrl: string; + name: string; + url: string; + id: string; + }; +} + +export type MergeMethod = 'MERGE' | 'REBASE' | 'SQUASH'; +export type MergeQueueState = 'AWAITING_CHECKS' | 'LOCKED' | 'MERGEABLE' | 'QUEUED' | 'UNMERGEABLE'; + +export interface PullRequest { + id: string; + databaseId: number; + number: number; + url: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + body: string; + bodyHTML: string; + title: string; + titleHTML: string; + assignees?: { + nodes: { + login: string; + url: string; + email: string; + avatarUrl: string; + id: string; + }[]; + }; + author: { + login: string; + url: string; + avatarUrl: string; + id: string; + }; + commits: { + nodes: { + commit: { + message: string; + }; + }[]; + }; + comments?: { + nodes: AbbreviatedIssueComment[]; + }; + createdAt: string; + updatedAt: string; + headRef?: Ref; + headRefName: string; + headRefOid: string; + headRepository?: RefRepository; + baseRef?: Ref; + baseRefName: string; + baseRefOid: string; + baseRepository: BaseRefRepository; + labels: { + nodes: { + name: string; + color: string; + }[]; + }; + merged: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeQueueEntry?: MergeQueueEntry | null; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + autoMergeRequest?: { + mergeMethod: MergeMethod; + }; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + isDraft?: boolean; + suggestedReviewers: SuggestedReviewerResponse[]; + projectItems?: { + nodes: { + project: { + id: string; + title: string; + }, + id: string + }[]; + }; + milestone?: { + title: string; + dueOn?: string; + id: string; + createdAt: string; + number: number; + }; + repository?: { + name: string; + owner: { + login: string; + }; + url: string; + }; +} + +export enum DefaultCommitTitle { + prTitle = 'PR_TITLE', + commitOrPrTitle = 'COMMIT_OR_PR_TITLE', + mergeMessage = 'MERGE_MESSAGE' +} + +export enum DefaultCommitMessage { + prBody = 'PR_BODY', + commitMessages = 'COMMIT_MESSAGES', + blank = 'BLANK', + prTitle = 'PR_TITLE' +} + +export interface PullRequestResponse { + repository: { + pullRequest: PullRequest; + } | null; + rateLimit: RateLimit; +} + +export interface PullRequestMergabilityResponse { + repository: { + pullRequest: { + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + }; + } | null; + rateLimit: RateLimit; +} + +export interface IssuesSearchResponse { + search: { + issueCount: number; + pageInfo: PageInfo; + edges: { + node: PullRequest; + }[]; + }; + rateLimit: RateLimit; +} + +export interface RepoProjectsResponse { + repository: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + } + } | null; +} + +export interface OrgProjectsResponse { + organization: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + pageInfo: PageInfo; + } + } +} + +export interface MilestoneIssuesResponse { + repository: { + milestones: { + nodes: { + dueOn: string; + createdAt: string; + title: string; + id: string; + number: number + issues: { + edges: { + node: PullRequest; + }[]; + }; + }[]; + pageInfo: PageInfo; + }; + } | null; +} + +export interface IssuesResponse { + repository: { + issues: { + edges: { + node: PullRequest; + }[]; + pageInfo: PageInfo; + }; + } | null; +} + +export interface PullRequestsResponse { + repository: { + pullRequests: { + nodes: PullRequest[] + } + } | null; +} + +export interface MaxIssueResponse { + repository: { + issues: { + edges: { + node: { + number: number; + }; + }[]; + }; + } | null; +} + +export interface ViewerPermissionResponse { + repository: { + viewerPermission: string; + } | null; +} + +export interface ForkDetailsResponse { + repository: ForkDetails; +} + +export interface QueryWithRateLimit { + rateLimit: RateLimit; +} +export interface RateLimit { + limit: number; + cost: number; + remaining: number; + resetAt: string; +} + +export interface ContributionsCollection { + commitContributionsByRepository: { + contributions: { + nodes: { + occurredAt: string; + }[]; + }; + repository: { + nameWithOwner: string; + }; + }[]; +} + +export interface UserResponse { + user: { + login: string; + avatarUrl?: string; + bio?: string; + company?: string; + location?: string; + name: string; + contributionsCollection: ContributionsCollection; + url: string; + id: string; + }; +} + +export interface FileContentResponse { + repository: { + object: { + text: string | undefined; + } + } | null; +} + +export interface StartReviewResponse { + addPullRequestReview: { + pullRequestReview: { + id: string; + }; + }; +} + +export interface StatusContext { + __typename: string; + id: string; + state: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + description: string | null; + context: string; + targetUrl: string | null; + avatarUrl: string | null; + isRequired: boolean; +} + +export interface CheckRun { + __typename: string; + id: string; + conclusion: + | 'ACTION_REQUIRED' + | 'CANCELLED' + | 'FAILURE' + | 'NEUTRAL' + | 'SKIPPED' + | 'STALE' + | 'SUCCESS' + | 'TIMED_OUT' + | null; + name: string; + title: string | null; + detailsUrl: string | null; + checkSuite?: { + app: { + logoUrl: string; + url: string; + } | null; + }; + isRequired: boolean; +} + +export function isCheckRun(x: CheckRun | StatusContext): x is CheckRun { + return x.__typename === 'CheckRun'; +} + +export interface ChecksReviewNode { + authorAssociation: 'MEMBER' | 'OWNER' | 'MANNEQUIN' | 'COLLABORATOR' | 'CONTRIBUTOR' | 'FIRST_TIME_CONTRIBUTOR' | 'FIRST_TIMER' | 'NONE'; + authorCanPushToRepository: boolean + state: 'PENDING' | 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'; + author: { + login: string; + } +} + +export interface GetChecksResponse { + repository: { + pullRequest: { + url: string; + latestReviews: { + nodes: ChecksReviewNode[]; + }; + reviewsRequestingChanges: { + nodes: ChecksReviewNode[]; + }; + baseRef: { + refUpdateRule: { + requiredApprovingReviewCount: number | null; + requiredStatusCheckContexts: string[] | null; + requiresCodeOwnerReviews: boolean; + viewerCanPush: boolean; + } | null; + }; + commits: { + nodes: { + commit: { + statusCheckRollup?: { + state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + contexts: { + nodes: (StatusContext | CheckRun)[]; + }; + }; + }; + }[] | undefined; + }; + }; + } | null; +} + +export interface ResolveReviewThreadResponse { + resolveReviewThread: { + thread: ReviewThread; + } +} + +export interface UnresolveReviewThreadResponse { + unresolveReviewThread: { + thread: ReviewThread; + } +} + +export interface PullRequestFilesResponse { + repository: { + pullRequest: { + files: { + nodes: { + path: string; + viewerViewedState: ViewedState + }[] + pageInfo: { + hasNextPage: boolean; + endCursor: string; + }; + } + } + } | null; +} diff --git a/src/github/interface.ts b/src/github/interface.ts index f4acc433b2..983fd6b911 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -1,263 +1,264 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export enum PRType { - Query, - All, - LocalPullRequest, -} - -export enum ReviewEvent { - Approve = 'APPROVE', - RequestChanges = 'REQUEST_CHANGES', - Comment = 'COMMENT', -} - -export enum GithubItemStateEnum { - Open, - Merged, - Closed, -} - -export enum PullRequestMergeability { - Mergeable, - NotMergeable, - Conflict, - Unknown, - Behind, -} - -export enum MergeQueueState { - AwaitingChecks, - Locked, - Mergeable, - Queued, - Unmergeable -} - -export interface ReviewState { - reviewer: IAccount | ITeam; - state: string; -} - -export interface IActor { - login: string; - avatarUrl?: string; - url: string; -} - -export interface IAccount extends IActor { - login: string; - id: string; - name?: string; - avatarUrl?: string; - url: string; - email?: string; -} - -export interface ITeam { - name?: string; - avatarUrl?: string; - url: string; - slug: string; - org: string; - id: string; -} - -export interface MergeQueueEntry { - position: number; - state: MergeQueueState; - url: string; -} - -export function reviewerId(reviewer: ITeam | IAccount): string { - return isTeam(reviewer) ? reviewer.id : reviewer.login; -} - -export function reviewerLabel(reviewer: ITeam | IAccount | IActor): string { - return isTeam(reviewer) ? (reviewer.name ?? reviewer.slug) : reviewer.login; -} - -export function isTeam(reviewer: ITeam | IAccount | IActor): reviewer is ITeam { - return 'org' in reviewer; -} - -export interface ISuggestedReviewer extends IAccount { - isAuthor: boolean; - isCommenter: boolean; -} - -export function isSuggestedReviewer( - reviewer: IAccount | ISuggestedReviewer | ITeam -): reviewer is ISuggestedReviewer { - return 'isAuthor' in reviewer && 'isCommenter' in reviewer; -} - -export interface IProject { - title: string; - id: string; -} - -export interface IProjectItem { - id: string; - project: IProject; -} - -export interface IMilestone { - title: string; - dueOn?: string | null; - createdAt: string; - id: string; - number: number; -} - -export interface MergePullRequest { - sha: string; - merged: boolean; - message: string; - documentation_url: string; -} - -export interface IRepository { - cloneUrl: string; - isInOrganization: boolean; - owner: string; - name: string; -} - -export interface IGitHubRef { - label: string; - ref: string; - sha: string; - repo: IRepository; -} - -export interface ILabel { - name: string; - color: string; - description?: string; -} - -export interface Issue { - id: number; - graphNodeId: string; - url: string; - number: number; - state: string; - body: string; - bodyHTML?: string; - title: string; - titleHTML: string; - assignees?: IAccount[]; - createdAt: string; - updatedAt: string; - user: IAccount; - labels: ILabel[]; - projectItems?: IProjectItem[]; - milestone?: IMilestone; - repositoryOwner?: string; - repositoryName?: string; - repositoryUrl?: string; - comments?: { - author: IAccount; - body: string; - databaseId: number; - }[]; -} - -export interface PullRequest extends Issue { - isDraft?: boolean; - isRemoteHeadDeleted?: boolean; - head?: IGitHubRef; - isRemoteBaseDeleted?: boolean; - base?: IGitHubRef; - commits: { - message: string; - }[]; - merged?: boolean; - mergeable?: PullRequestMergeability; - mergeQueueEntry?: MergeQueueEntry | null; - autoMerge?: boolean; - autoMergeMethod?: MergeMethod; - allowAutoMerge?: boolean; - mergeCommitMeta?: { title: string, description: string }; - squashCommitMeta?: { title: string, description: string }; - suggestedReviewers?: ISuggestedReviewer[]; -} - -export interface IRawFileChange { - filename: string; - previous_filename?: string; - additions: number; - deletions: number; - changes: number; - status: string; - raw_url: string; - blob_url: string; - patch: string; -} - -export interface IPullRequestsPagingOptions { - fetchNextPage: boolean; - fetchOnePagePerRepo?: boolean; -} - -export interface IPullRequestEditData { - body?: string; - title?: string; -} - -export type MergeMethod = 'merge' | 'squash' | 'rebase'; - -export type MergeMethodsAvailability = { - [method in MergeMethod]: boolean; -}; - -export type RepoAccessAndMergeMethods = { - hasWritePermission: boolean; - mergeMethodsAvailability: MergeMethodsAvailability; - viewerCanAutoMerge: boolean; -}; - -export interface User extends IAccount { - company?: string; - location?: string; - bio?: string; - commitContributions: { - createdAt: Date; - repoNameWithOwner: string; - }[]; -} - -export enum CheckState { - Success = 'success', - Failure = 'failure', - Neutral = 'neutral', - Pending = 'pending', - Unknown = 'unknown' -} - -export interface PullRequestCheckStatus { - id: string; - url: string | undefined; - avatarUrl: string | undefined; - state: CheckState; - description: string | null; - targetUrl: string | null; - context: string; - isRequired: boolean; -} - -export interface PullRequestChecks { - state: CheckState; - statuses: PullRequestCheckStatus[]; -} - -export interface PullRequestReviewRequirement { - count: number; - state: CheckState; - approvals: string[]; - requestedChanges: string[]; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export enum PRType { + Query, + All, + LocalPullRequest, +} + +export enum ReviewEvent { + Approve = 'APPROVE', + RequestChanges = 'REQUEST_CHANGES', + Comment = 'COMMENT', +} + +export enum GithubItemStateEnum { + Open, + Merged, + Closed, +} + +export enum PullRequestMergeability { + Mergeable, + NotMergeable, + Conflict, + Unknown, + Behind, +} + +export enum MergeQueueState { + AwaitingChecks, + Locked, + Mergeable, + Queued, + Unmergeable +} + +export interface ReviewState { + reviewer: IAccount | ITeam; + state: string; +} + +export interface IActor { + login: string; + avatarUrl?: string; + url: string; +} + +export interface IAccount extends IActor { + login: string; + id: string; + name?: string; + avatarUrl?: string; + url: string; + email?: string; +} + +export interface ITeam { + name?: string; + avatarUrl?: string; + url: string; + slug: string; + org: string; + id: string; +} + +export interface MergeQueueEntry { + position: number; + state: MergeQueueState; + url: string; +} + +export function reviewerId(reviewer: ITeam | IAccount): string { + return isTeam(reviewer) ? reviewer.id : reviewer.login; +} + +export function reviewerLabel(reviewer: ITeam | IAccount | IActor): string { + return isTeam(reviewer) ? (reviewer.name ?? reviewer.slug) : reviewer.login; +} + +export function isTeam(reviewer: ITeam | IAccount | IActor): reviewer is ITeam { + return 'org' in reviewer; +} + +export interface ISuggestedReviewer extends IAccount { + isAuthor: boolean; + isCommenter: boolean; +} + +export function isSuggestedReviewer( + reviewer: IAccount | ISuggestedReviewer | ITeam +): reviewer is ISuggestedReviewer { + return 'isAuthor' in reviewer && 'isCommenter' in reviewer; +} + +export interface IProject { + title: string; + id: string; +} + +export interface IProjectItem { + id: string; + project: IProject; +} + +export interface IMilestone { + title: string; + dueOn?: string | null; + createdAt: string; + id: string; + number: number; +} + +export interface MergePullRequest { + sha: string; + merged: boolean; + message: string; + documentation_url: string; +} + +export interface IRepository { + cloneUrl: string; + isInOrganization: boolean; + owner: string; + name: string; +} + +export interface IGitHubRef { + label: string; + ref: string; + sha: string; + repo: IRepository; +} + +export interface ILabel { + name: string; + color: string; + description?: string; +} + +export interface Issue { + id: number; + graphNodeId: string; + url: string; + number: number; + state: string; + body: string; + bodyHTML?: string; + title: string; + titleHTML: string; + assignees?: IAccount[]; + createdAt: string; + updatedAt: string; + user: IAccount; + labels: ILabel[]; + projectItems?: IProjectItem[]; + milestone?: IMilestone; + repositoryOwner?: string; + repositoryName?: string; + repositoryUrl?: string; + comments?: { + author: IAccount; + body: string; + databaseId: number; + }[]; +} + +export interface PullRequest extends Issue { + isDraft?: boolean; + isRemoteHeadDeleted?: boolean; + head?: IGitHubRef; + isRemoteBaseDeleted?: boolean; + base?: IGitHubRef; + commits: { + message: string; + }[]; + merged?: boolean; + mergeable?: PullRequestMergeability; + mergeQueueEntry?: MergeQueueEntry | null; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; + suggestedReviewers?: ISuggestedReviewer[]; +} + +export interface IRawFileChange { + filename: string; + previous_filename?: string; + additions: number; + deletions: number; + changes: number; + status: string; + raw_url: string; + blob_url: string; + patch: string; +} + +export interface IPullRequestsPagingOptions { + fetchNextPage: boolean; + fetchOnePagePerRepo?: boolean; +} + +export interface IPullRequestEditData { + body?: string; + title?: string; +} + +export type MergeMethod = 'merge' | 'squash' | 'rebase'; + +export type MergeMethodsAvailability = { + [method in MergeMethod]: boolean; +}; + +export type RepoAccessAndMergeMethods = { + hasWritePermission: boolean; + mergeMethodsAvailability: MergeMethodsAvailability; + viewerCanAutoMerge: boolean; +}; + +export interface User extends IAccount { + company?: string; + location?: string; + bio?: string; + commitContributions: { + createdAt: Date; + repoNameWithOwner: string; + }[]; +} + +export enum CheckState { + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', + Pending = 'pending', + Unknown = 'unknown' +} + +export interface PullRequestCheckStatus { + id: string; + url: string | undefined; + avatarUrl: string | undefined; + state: CheckState; + description: string | null; + targetUrl: string | null; + context: string; + isRequired: boolean; +} + +export interface PullRequestChecks { + state: CheckState; + statuses: PullRequestCheckStatus[]; +} + +export interface PullRequestReviewRequirement { + count: number; + state: CheckState; + approvals: string[]; + requestedChanges: string[]; +} diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 11a9c19bdc..747d697015 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -1,361 +1,362 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { TimelineEvent } from '../common/timelineEvent'; -import { formatError } from '../common/utils'; -import { OctokitCommon } from './common'; -import { GitHubRepository } from './githubRepository'; -import { - AddIssueCommentResponse, - AddPullRequestToProjectResponse, - EditIssueCommentResponse, - TimelineEventsResponse, - UpdatePullRequestResponse, -} from './graphql'; -import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, IPullRequestEditData, Issue } from './interface'; -import { parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils'; - -export class IssueModel { - static ID = 'IssueModel'; - public id: number; - public graphNodeId: string; - public number: number; - public title: string; - public titleHTML: string; - public html_url: string; - public state: GithubItemStateEnum = GithubItemStateEnum.Open; - public author: IAccount; - public assignees?: IAccount[]; - public createdAt: string; - public updatedAt: string; - public milestone?: IMilestone; - public readonly githubRepository: GitHubRepository; - public readonly remote: Remote; - public item: TItem; - public bodyHTML?: string; - - private _onDidInvalidate = new vscode.EventEmitter(); - public onDidInvalidate = this._onDidInvalidate.event; - - constructor(githubRepository: GitHubRepository, remote: Remote, item: TItem, skipUpdate: boolean = false) { - this.githubRepository = githubRepository; - this.remote = remote; - this.item = item; - - if (!skipUpdate) { - this.update(item); - } - } - - public invalidate() { - // Something about the PR data is stale - this._onDidInvalidate.fire(); - } - - public get isOpen(): boolean { - return this.state === GithubItemStateEnum.Open; - } - - public get isClosed(): boolean { - return this.state === GithubItemStateEnum.Closed; - } - - public get isMerged(): boolean { - return this.state === GithubItemStateEnum.Merged; - } - - public get userAvatar(): string | undefined { - if (this.item) { - return this.item.user.avatarUrl; - } - - return undefined; - } - - public get userAvatarUri(): vscode.Uri | undefined { - if (this.item) { - const key = this.userAvatar; - if (key) { - const uri = vscode.Uri.parse(`${key}&s=${64}`); - - // hack, to ensure queries are not wrongly encoded. - const originalToStringFn = uri.toString; - uri.toString = function (_skipEncoding?: boolean | undefined) { - return originalToStringFn.call(uri, true); - }; - - return uri; - } - } - - return undefined; - } - - public get body(): string { - if (this.item) { - return this.item.body; - } - return ''; - } - - protected updateState(state: string) { - if (state.toLowerCase() === 'open') { - this.state = GithubItemStateEnum.Open; - } else { - this.state = GithubItemStateEnum.Closed; - } - } - - update(issue: TItem): void { - this.id = issue.id; - this.graphNodeId = issue.graphNodeId; - this.number = issue.number; - this.title = issue.title; - if (issue.titleHTML) { - this.titleHTML = issue.titleHTML; - } - if (!this.bodyHTML || (issue.body !== this.body)) { - this.bodyHTML = issue.bodyHTML; - } - this.html_url = issue.url; - this.author = issue.user; - this.milestone = issue.milestone; - this.createdAt = issue.createdAt; - this.updatedAt = issue.updatedAt; - - this.updateState(issue.state); - - if (issue.assignees) { - this.assignees = issue.assignees; - } - - this.item = issue; - } - - equals(other: IssueModel | undefined): boolean { - if (!other) { - return false; - } - - if (this.number !== other.number) { - return false; - } - - if (this.html_url !== other.html_url) { - return false; - } - - return true; - } - - async edit(toEdit: IPullRequestEditData): Promise<{ body: string; bodyHTML: string; title: string; titleHTML: string }> { - try { - const { mutate, schema } = await this.githubRepository.ensure(); - - const { data } = await mutate({ - mutation: schema.UpdatePullRequest, - variables: { - input: { - pullRequestId: this.graphNodeId, - body: toEdit.body, - title: toEdit.title, - }, - }, - }); - if (data?.updatePullRequest.pullRequest) { - this.item.body = data.updatePullRequest.pullRequest.body; - this.bodyHTML = data.updatePullRequest.pullRequest.bodyHTML; - this.title = data.updatePullRequest.pullRequest.title; - this.titleHTML = data.updatePullRequest.pullRequest.titleHTML; - this.invalidate(); - } - return data!.updatePullRequest.pullRequest; - } catch (e) { - throw new Error(formatError(e)); - } - } - - canEdit(): Promise { - const username = this.author && this.author.login; - return this.githubRepository.isCurrentUser(username); - } - - async getIssueComments(): Promise { - Logger.debug(`Fetch issue comments of PR #${this.number} - enter`, IssueModel.ID); - const { octokit, remote } = await this.githubRepository.ensure(); - - const promise = await octokit.call(octokit.api.issues.listComments, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - per_page: 100, - }); - Logger.debug(`Fetch issue comments of PR #${this.number} - done`, IssueModel.ID); - - return promise.data; - } - - async createIssueComment(text: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.AddIssueComment, - variables: { - input: { - subjectId: this.graphNodeId, - body: text, - }, - }, - }); - - return parseGraphQlIssueComment(data!.addComment.commentEdge.node, this.githubRepository); - } - - async editIssueComment(comment: IComment, text: string): Promise { - try { - const { mutate, schema } = await this.githubRepository.ensure(); - - const { data } = await mutate({ - mutation: schema.EditIssueComment, - variables: { - input: { - id: comment.graphNodeId, - body: text, - }, - }, - }); - - return parseGraphQlIssueComment(data!.updateIssueComment.issueComment, this.githubRepository); - } catch (e) { - throw new Error(formatError(e)); - } - } - - async deleteIssueComment(commentId: string): Promise { - try { - const { octokit, remote } = await this.githubRepository.ensure(); - - await octokit.call(octokit.api.issues.deleteComment, { - owner: remote.owner, - repo: remote.repositoryName, - comment_id: Number(commentId), - }); - } catch (e) { - throw new Error(formatError(e)); - } - } - - async setLabels(labels: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - try { - await octokit.call(octokit.api.issues.setLabels, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - labels, - }); - } catch (e) { - // We don't get a nice error message from the API when setting labels fails. - // Since adding labels isn't a critical part of the PR creation path it's safe to catch all errors that come from setting labels. - Logger.error(`Failed to add labels to PR #${this.number}`, IssueModel.ID); - vscode.window.showWarningMessage(vscode.l10n.t('Some, or all, labels could not be added to the pull request.')); - } - } - - async removeLabel(label: string): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.removeLabel, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - name: label, - }); - } - - public async removeProjects(projectItems: IProjectItem[]): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - - try { - await Promise.all(projectItems.map(project => - mutate({ - mutation: schema.RemovePullRequestFromProject, - variables: { - input: { - itemId: project.id, - projectId: project.project.id - }, - }, - }))); - this.item.projectItems = this.item.projectItems?.filter(project => !projectItems.find(p => p.project.id === project.project.id)); - } catch (err) { - Logger.error(err, IssueModel.ID); - } - } - - private async addProjects(projects: IProject[]): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - - try { - const itemIds = await Promise.all(projects.map(project => - mutate({ - mutation: schema.AddPullRequestToProject, - variables: { - input: { - contentId: this.item.graphNodeId, - projectId: project.id - }, - }, - }))); - if (!this.item.projectItems) { - this.item.projectItems = []; - } - this.item.projectItems.push(...projects.map((project, index) => { return { project, id: itemIds[index].data!.addProjectV2ItemById.item.id }; })); - } catch (err) { - Logger.error(err, IssueModel.ID); - } - } - - async updateProjects(projects: IProject[]): Promise { - const projectsToAdd: IProject[] = projects.filter(project => !this.item.projectItems?.find(p => p.project.id === project.id)); - const projectsToRemove: IProjectItem[] = this.item.projectItems?.filter(project => !projects.find(p => p.id === project.project.id)) ?? []; - await this.removeProjects(projectsToRemove); - await this.addProjects(projectsToAdd); - return this.item.projectItems; - } - - async getIssueTimelineEvents(): Promise { - Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, IssueModel.ID); - const githubRepository = this.githubRepository; - const { query, remote, schema } = await githubRepository.ensure(); - - try { - const { data } = await query({ - query: schema.IssueTimelineEvents, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - - if (data.repository === null) { - Logger.error('Unexpected null repository when getting issue timeline events', IssueModel.ID); - return []; - } - const ret = data.repository.pullRequest.timelineItems.nodes; - const events = parseGraphQLTimelineEvents(ret, githubRepository); - - return events; - } catch (e) { - console.log(e); - return []; - } - } - - -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { IComment } from '../common/comment'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { TimelineEvent } from '../common/timelineEvent'; +import { formatError } from '../common/utils'; +import { OctokitCommon } from './common'; +import { GitHubRepository } from './githubRepository'; +import { + AddIssueCommentResponse, + AddPullRequestToProjectResponse, + EditIssueCommentResponse, + TimelineEventsResponse, + UpdatePullRequestResponse, +} from './graphql'; +import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, IPullRequestEditData, Issue } from './interface'; +import { parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils'; + +export class IssueModel { + static ID = 'IssueModel'; + public id: number; + public graphNodeId: string; + public number: number; + public title: string; + public titleHTML: string; + public html_url: string; + public state: GithubItemStateEnum = GithubItemStateEnum.Open; + public author: IAccount; + public assignees?: IAccount[]; + public createdAt: string; + public updatedAt: string; + public milestone?: IMilestone; + public readonly githubRepository: GitHubRepository; + public readonly remote: Remote; + public item: TItem; + public bodyHTML?: string; + + private _onDidInvalidate = new vscode.EventEmitter(); + public onDidInvalidate = this._onDidInvalidate.event; + + constructor(githubRepository: GitHubRepository, remote: Remote, item: TItem, skipUpdate: boolean = false) { + this.githubRepository = githubRepository; + this.remote = remote; + this.item = item; + + if (!skipUpdate) { + this.update(item); + } + } + + public invalidate() { + // Something about the PR data is stale + this._onDidInvalidate.fire(); + } + + public get isOpen(): boolean { + return this.state === GithubItemStateEnum.Open; + } + + public get isClosed(): boolean { + return this.state === GithubItemStateEnum.Closed; + } + + public get isMerged(): boolean { + return this.state === GithubItemStateEnum.Merged; + } + + public get userAvatar(): string | undefined { + if (this.item) { + return this.item.user.avatarUrl; + } + + return undefined; + } + + public get userAvatarUri(): vscode.Uri | undefined { + if (this.item) { + const key = this.userAvatar; + if (key) { + const uri = vscode.Uri.parse(`${key}&s=${64}`); + + // hack, to ensure queries are not wrongly encoded. + const originalToStringFn = uri.toString; + uri.toString = function (_skipEncoding?: boolean | undefined) { + return originalToStringFn.call(uri, true); + }; + + return uri; + } + } + + return undefined; + } + + public get body(): string { + if (this.item) { + return this.item.body; + } + return ''; + } + + protected updateState(state: string) { + if (state.toLowerCase() === 'open') { + this.state = GithubItemStateEnum.Open; + } else { + this.state = GithubItemStateEnum.Closed; + } + } + + update(issue: TItem): void { + this.id = issue.id; + this.graphNodeId = issue.graphNodeId; + this.number = issue.number; + this.title = issue.title; + if (issue.titleHTML) { + this.titleHTML = issue.titleHTML; + } + if (!this.bodyHTML || (issue.body !== this.body)) { + this.bodyHTML = issue.bodyHTML; + } + this.html_url = issue.url; + this.author = issue.user; + this.milestone = issue.milestone; + this.createdAt = issue.createdAt; + this.updatedAt = issue.updatedAt; + + this.updateState(issue.state); + + if (issue.assignees) { + this.assignees = issue.assignees; + } + + this.item = issue; + } + + equals(other: IssueModel | undefined): boolean { + if (!other) { + return false; + } + + if (this.number !== other.number) { + return false; + } + + if (this.html_url !== other.html_url) { + return false; + } + + return true; + } + + async edit(toEdit: IPullRequestEditData): Promise<{ body: string; bodyHTML: string; title: string; titleHTML: string }> { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.UpdatePullRequest, + variables: { + input: { + pullRequestId: this.graphNodeId, + body: toEdit.body, + title: toEdit.title, + }, + }, + }); + if (data?.updatePullRequest.pullRequest) { + this.item.body = data.updatePullRequest.pullRequest.body; + this.bodyHTML = data.updatePullRequest.pullRequest.bodyHTML; + this.title = data.updatePullRequest.pullRequest.title; + this.titleHTML = data.updatePullRequest.pullRequest.titleHTML; + this.invalidate(); + } + return data!.updatePullRequest.pullRequest; + } catch (e) { + throw new Error(formatError(e)); + } + } + + canEdit(): Promise { + const username = this.author && this.author.login; + return this.githubRepository.isCurrentUser(username); + } + + async getIssueComments(): Promise { + Logger.debug(`Fetch issue comments of PR #${this.number} - enter`, IssueModel.ID); + const { octokit, remote } = await this.githubRepository.ensure(); + + const promise = await octokit.call(octokit.api.issues.listComments, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + per_page: 100, + }); + Logger.debug(`Fetch issue comments of PR #${this.number} - done`, IssueModel.ID); + + return promise.data; + } + + async createIssueComment(text: string): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddIssueComment, + variables: { + input: { + subjectId: this.graphNodeId, + body: text, + }, + }, + }); + + return parseGraphQlIssueComment(data!.addComment.commentEdge.node, this.githubRepository); + } + + async editIssueComment(comment: IComment, text: string): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.EditIssueComment, + variables: { + input: { + id: comment.graphNodeId, + body: text, + }, + }, + }); + + return parseGraphQlIssueComment(data!.updateIssueComment.issueComment, this.githubRepository); + } catch (e) { + throw new Error(formatError(e)); + } + } + + async deleteIssueComment(commentId: string): Promise { + try { + const { octokit, remote } = await this.githubRepository.ensure(); + + await octokit.call(octokit.api.issues.deleteComment, { + owner: remote.owner, + repo: remote.repositoryName, + comment_id: Number(commentId), + }); + } catch (e) { + throw new Error(formatError(e)); + } + } + + async setLabels(labels: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + try { + await octokit.call(octokit.api.issues.setLabels, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + labels, + }); + } catch (e) { + // We don't get a nice error message from the API when setting labels fails. + // Since adding labels isn't a critical part of the PR creation path it's safe to catch all errors that come from setting labels. + Logger.error(`Failed to add labels to PR #${this.number}`, IssueModel.ID); + vscode.window.showWarningMessage(vscode.l10n.t('Some, or all, labels could not be added to the pull request.')); + } + } + + async removeLabel(label: string): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.issues.removeLabel, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + name: label, + }); + } + + public async removeProjects(projectItems: IProjectItem[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + await Promise.all(projectItems.map(project => + mutate({ + mutation: schema.RemovePullRequestFromProject, + variables: { + input: { + itemId: project.id, + projectId: project.project.id + }, + }, + }))); + this.item.projectItems = this.item.projectItems?.filter(project => !projectItems.find(p => p.project.id === project.project.id)); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + private async addProjects(projects: IProject[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + const itemIds = await Promise.all(projects.map(project => + mutate({ + mutation: schema.AddPullRequestToProject, + variables: { + input: { + contentId: this.item.graphNodeId, + projectId: project.id + }, + }, + }))); + if (!this.item.projectItems) { + this.item.projectItems = []; + } + this.item.projectItems.push(...projects.map((project, index) => { return { project, id: itemIds[index].data!.addProjectV2ItemById.item.id }; })); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + async updateProjects(projects: IProject[]): Promise { + const projectsToAdd: IProject[] = projects.filter(project => !this.item.projectItems?.find(p => p.project.id === project.id)); + const projectsToRemove: IProjectItem[] = this.item.projectItems?.filter(project => !projects.find(p => p.id === project.project.id)) ?? []; + await this.removeProjects(projectsToRemove); + await this.addProjects(projectsToAdd); + return this.item.projectItems; + } + + async getIssueTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, IssueModel.ID); + const githubRepository = this.githubRepository; + const { query, remote, schema } = await githubRepository.ensure(); + + try { + const { data } = await query({ + query: schema.IssueTimelineEvents, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue timeline events', IssueModel.ID); + return []; + } + const ret = data.repository.pullRequest.timelineItems.nodes; + const events = parseGraphQLTimelineEvents(ret, githubRepository); + + return events; + } catch (e) { + console.log(e); + return []; + } + } + + +} diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index ab978acb19..f35faad62f 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -1,415 +1,416 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { asPromise, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; -import { DescriptionNode } from '../view/treeNodes/descriptionNode'; -import { OctokitCommon } from './common'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { ILabel } from './interface'; -import { IssueModel } from './issueModel'; -import { getLabelOptions } from './quickPicks'; - -export class IssueOverviewPanel extends WebviewBase { - public static ID: string = 'PullRequestOverviewPanel'; - /** - * Track the currently panel. Only allow a single panel to exist at a time. - */ - public static currentPanel?: IssueOverviewPanel; - - private static readonly _viewType: string = 'IssueOverview'; - - protected readonly _panel: vscode.WebviewPanel; - protected _disposables: vscode.Disposable[] = []; - protected _descriptionNode: DescriptionNode; - protected _item: TItem; - protected _folderRepositoryManager: FolderRepositoryManager; - protected _scrollPosition = { x: 0, y: 0 }; - - public static async createOrShow( - extensionUri: vscode.Uri, - folderRepositoryManager: FolderRepositoryManager, - issue: IssueModel, - toTheSide: Boolean = false, - ) { - const activeColumn = toTheSide - ? vscode.ViewColumn.Beside - : vscode.window.activeTextEditor - ? vscode.window.activeTextEditor.viewColumn - : vscode.ViewColumn.One; - - // If we already have a panel, show it. - // Otherwise, create a new panel. - if (IssueOverviewPanel.currentPanel) { - IssueOverviewPanel.currentPanel._panel.reveal(activeColumn, true); - } else { - const title = `Issue #${issue.number.toString()}`; - IssueOverviewPanel.currentPanel = new IssueOverviewPanel( - extensionUri, - activeColumn || vscode.ViewColumn.Active, - title, - folderRepositoryManager, - ); - } - - await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); - } - - public static refresh(): void { - if (this.currentPanel) { - this.currentPanel.refreshPanel(); - } - } - - protected setPanelTitle(title: string): void { - try { - this._panel.title = title; - } catch (e) { - // The webview can be disposed at the time that we try to set the title if the user has closed - // it while it's still loading. - } - } - - protected constructor( - private readonly _extensionUri: vscode.Uri, - column: vscode.ViewColumn, - title: string, - folderRepositoryManager: FolderRepositoryManager, - type: string = IssueOverviewPanel._viewType, - ) { - super(); - this._folderRepositoryManager = folderRepositoryManager; - - // Create and show a new webview panel - this._panel = vscode.window.createWebviewPanel(type, title, column, { - // Enable javascript in the webview - enableScripts: true, - retainContextWhenHidden: true, - - // And restrict the webview to only loading content from our extension's `dist` directory. - localResourceRoots: [vscode.Uri.joinPath(_extensionUri, 'dist')], - }); - - this._webview = this._panel.webview; - super.initialize(); - - // Listen for when the panel is disposed - // This happens when the user closes the panel or when the panel is closed programmatically - this._panel.onDidDispose(() => this.dispose(), null, this._disposables); - - this._folderRepositoryManager.onDidChangeActiveIssue( - _ => { - if (this._folderRepositoryManager && this._item) { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activeIssue); - this._postMessage({ - command: 'pr.update-checkout-status', - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - }); - } - }, - null, - this._disposables, - ); - } - - public async refreshPanel(): Promise { - if (this._panel && this._panel.visible) { - this.update(this._folderRepositoryManager, this._item); - } - } - - public async updateIssue(issueModel: IssueModel): Promise { - return Promise.all([ - this._folderRepositoryManager.resolveIssue( - issueModel.remote.owner, - issueModel.remote.repositoryName, - issueModel.number, - ), - issueModel.getIssueTimelineEvents(), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(issueModel), - ]) - .then(result => { - const [issue, timelineEvents, defaultBranch] = result; - if (!issue) { - throw new Error( - `Fail to resolve issue #${issueModel.number} in ${issueModel.remote.owner}/${issueModel.remote.repositoryName}`, - ); - } - - this._item = issue as TItem; - this.setPanelTitle(`Pull Request #${issueModel.number.toString()}`); - - Logger.debug('pr.initialize', IssueOverviewPanel.ID); - this._postMessage({ - command: 'pr.initialize', - pullrequest: { - number: this._item.number, - title: this._item.title, - url: this._item.html_url, - createdAt: this._item.createdAt, - body: this._item.body, - bodyHTML: this._item.bodyHTML, - labels: this._item.item.labels, - author: { - login: this._item.author.login, - name: this._item.author.name, - avatarUrl: this._item.userAvatar, - url: this._item.author.url, - }, - state: this._item.state, - events: timelineEvents, - repositoryDefaultBranch: defaultBranch, - canEdit: true, - // TODO@eamodio What is status? - status: /*status ? status :*/ { statuses: [] }, - isIssue: true, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark - }, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); - }); - } - - public async update(foldersManager: FolderRepositoryManager, issueModel: IssueModel): Promise { - this._folderRepositoryManager = foldersManager; - this._postMessage({ - command: 'set-scroll', - scrollPosition: this._scrollPosition, - }); - - this._panel.webview.html = this.getHtmlForWebview(issueModel.number.toString()); - return this.updateIssue(issueModel); - } - - protected async _onDidReceiveMessage(message: IRequestMessage) { - const result = await super._onDidReceiveMessage(message); - if (result !== this.MESSAGE_UNHANDLED) { - return; - } - - switch (message.command) { - case 'alert': - vscode.window.showErrorMessage(message.args); - return; - case 'pr.close': - return this.close(message); - case 'pr.comment': - return this.createComment(message); - case 'scroll': - this._scrollPosition = message.args.scrollPosition; - return; - case 'pr.edit-comment': - return this.editComment(message); - case 'pr.delete-comment': - return this.deleteComment(message); - case 'pr.edit-description': - return this.editDescription(message); - case 'pr.edit-title': - return this.editTitle(message); - case 'pr.refresh': - this.refreshPanel(); - return; - case 'pr.add-labels': - return this.addLabels(message); - case 'pr.remove-label': - return this.removeLabel(message); - case 'pr.debug': - return this.webviewDebug(message); - default: - return this.MESSAGE_UNHANDLED; - } - } - - private async addLabels(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); - try { - let newLabels: ILabel[] = []; - - quickPick.busy = true; - quickPick.canSelectMany = true; - quickPick.show(); - quickPick.items = await (getLabelOptions(this._folderRepositoryManager, this._item.item.labels, this._item.remote).then(options => { - newLabels = options.newLabels; - return options.labelPicks; - })); - quickPick.selectedItems = quickPick.items.filter(item => item.picked); - - quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick.selectedItems; - }); - const hidePromise = asPromise(quickPick.onDidHide); - const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); - quickPick.busy = true; - - if (labelsToAdd) { - await this._item.setLabels(labelsToAdd.map(r => r.label)); - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - - this._item.item.labels = addedLabels; - - await this._replyMessage(message, { - added: addedLabels, - }); - } - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } finally { - quickPick.hide(); - quickPick.dispose(); - } - } - - private async removeLabel(message: IRequestMessage): Promise { - try { - await this._item.removeLabel(message.args); - - const index = this._item.item.labels.findIndex(label => label.name === message.args); - this._item.item.labels.splice(index, 1); - - this._replyMessage(message, {}); - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } - } - - private webviewDebug(message: IRequestMessage): void { - Logger.debug(message.args, IssueOverviewPanel.ID); - } - - private editDescription(message: IRequestMessage<{ text: string }>) { - this._item - .edit({ body: message.args.text }) - .then(result => { - this._replyMessage(message, { body: result.body, bodyHTML: result.bodyHTML }); - }) - .catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`); - }); - } - private editTitle(message: IRequestMessage<{ text: string }>) { - return this._item - .edit({ title: message.args.text }) - .then(result => { - return this._replyMessage(message, { titleHTML: result.titleHTML }); - }) - .catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(`Editing title failed: ${formatError(e)}`); - }); - } - - protected editCommentPromise(comment: IComment, text: string): Promise { - return this._item.editIssueComment(comment, text); - } - - private editComment(message: IRequestMessage<{ comment: IComment; text: string }>) { - this.editCommentPromise(message.args.comment, message.args.text) - .then(result => { - this._replyMessage(message, { - body: result.body, - bodyHTML: result.bodyHTML, - }); - }) - .catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(formatError(e)); - }); - } - - protected deleteCommentPromise(comment: IComment): Promise { - return this._item.deleteIssueComment(comment.id.toString()); - } - - private deleteComment(message: IRequestMessage) { - vscode.window - .showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, 'Delete') - .then(value => { - if (value === 'Delete') { - this.deleteCommentPromise(message.args) - .then(_ => { - this._replyMessage(message, {}); - }) - .catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(formatError(e)); - }); - } - }); - } - - private close(message: IRequestMessage): void { - vscode.commands - .executeCommand('pr.close', this._item, message.args) - .then(comment => { - if (comment) { - this._replyMessage(message, { - value: comment, - }); - } else { - this._throwError(message, 'Close cancelled'); - } - }); - } - - private createComment(message: IRequestMessage) { - this._item.createIssueComment(message.args).then(comment => { - this._replyMessage(message, { - value: comment, - }); - }); - } - - protected set _currentPanel(panel: IssueOverviewPanel | undefined) { - IssueOverviewPanel.currentPanel = panel; - } - - public dispose() { - this._currentPanel = undefined; - - // Clean up our resources - this._panel.dispose(); - this._webview = undefined; - - while (this._disposables.length) { - const x = this._disposables.pop(); - if (x) { - x.dispose(); - } - } - } - - protected getHtmlForWebview(number: string) { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); - - return ` - - - - - - - Pull Request #${number} - - -
- - -`; - } - - public getCurrentTitle(): string { - return this._panel.title; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import { IComment } from '../common/comment'; +import Logger from '../common/logger'; +import { asPromise, formatError } from '../common/utils'; +import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; +import { DescriptionNode } from '../view/treeNodes/descriptionNode'; +import { OctokitCommon } from './common'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { ILabel } from './interface'; +import { IssueModel } from './issueModel'; +import { getLabelOptions } from './quickPicks'; + +export class IssueOverviewPanel extends WebviewBase { + public static ID: string = 'PullRequestOverviewPanel'; + /** + * Track the currently panel. Only allow a single panel to exist at a time. + */ + public static currentPanel?: IssueOverviewPanel; + + private static readonly _viewType: string = 'IssueOverview'; + + protected readonly _panel: vscode.WebviewPanel; + protected _disposables: vscode.Disposable[] = []; + protected _descriptionNode: DescriptionNode; + protected _item: TItem; + protected _folderRepositoryManager: FolderRepositoryManager; + protected _scrollPosition = { x: 0, y: 0 }; + + public static async createOrShow( + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + issue: IssueModel, + toTheSide: Boolean = false, + ) { + const activeColumn = toTheSide + ? vscode.ViewColumn.Beside + : vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : vscode.ViewColumn.One; + + // If we already have a panel, show it. + // Otherwise, create a new panel. + if (IssueOverviewPanel.currentPanel) { + IssueOverviewPanel.currentPanel._panel.reveal(activeColumn, true); + } else { + const title = `Issue #${issue.number.toString()}`; + IssueOverviewPanel.currentPanel = new IssueOverviewPanel( + extensionUri, + activeColumn || vscode.ViewColumn.Active, + title, + folderRepositoryManager, + ); + } + + await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); + } + + public static refresh(): void { + if (this.currentPanel) { + this.currentPanel.refreshPanel(); + } + } + + protected setPanelTitle(title: string): void { + try { + this._panel.title = title; + } catch (e) { + // The webview can be disposed at the time that we try to set the title if the user has closed + // it while it's still loading. + } + } + + protected constructor( + private readonly _extensionUri: vscode.Uri, + column: vscode.ViewColumn, + title: string, + folderRepositoryManager: FolderRepositoryManager, + type: string = IssueOverviewPanel._viewType, + ) { + super(); + this._folderRepositoryManager = folderRepositoryManager; + + // Create and show a new webview panel + this._panel = vscode.window.createWebviewPanel(type, title, column, { + // Enable javascript in the webview + enableScripts: true, + retainContextWhenHidden: true, + + // And restrict the webview to only loading content from our extension's `dist` directory. + localResourceRoots: [vscode.Uri.joinPath(_extensionUri, 'dist')], + }); + + this._webview = this._panel.webview; + super.initialize(); + + // Listen for when the panel is disposed + // This happens when the user closes the panel or when the panel is closed programmatically + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._folderRepositoryManager.onDidChangeActiveIssue( + _ => { + if (this._folderRepositoryManager && this._item) { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activeIssue); + this._postMessage({ + command: 'pr.update-checkout-status', + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + }); + } + }, + null, + this._disposables, + ); + } + + public async refreshPanel(): Promise { + if (this._panel && this._panel.visible) { + this.update(this._folderRepositoryManager, this._item); + } + } + + public async updateIssue(issueModel: IssueModel): Promise { + return Promise.all([ + this._folderRepositoryManager.resolveIssue( + issueModel.remote.owner, + issueModel.remote.repositoryName, + issueModel.number, + ), + issueModel.getIssueTimelineEvents(), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(issueModel), + ]) + .then(result => { + const [issue, timelineEvents, defaultBranch] = result; + if (!issue) { + throw new Error( + `Fail to resolve issue #${issueModel.number} in ${issueModel.remote.owner}/${issueModel.remote.repositoryName}`, + ); + } + + this._item = issue as TItem; + this.setPanelTitle(`Pull Request #${issueModel.number.toString()}`); + + Logger.debug('pr.initialize', IssueOverviewPanel.ID); + this._postMessage({ + command: 'pr.initialize', + pullrequest: { + number: this._item.number, + title: this._item.title, + url: this._item.html_url, + createdAt: this._item.createdAt, + body: this._item.body, + bodyHTML: this._item.bodyHTML, + labels: this._item.item.labels, + author: { + login: this._item.author.login, + name: this._item.author.name, + avatarUrl: this._item.userAvatar, + url: this._item.author.url, + }, + state: this._item.state, + events: timelineEvents, + repositoryDefaultBranch: defaultBranch, + canEdit: true, + // TODO@eamodio What is status? + status: /*status ? status :*/ { statuses: [] }, + isIssue: true, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark + }, + }); + }) + .catch(e => { + vscode.window.showErrorMessage(formatError(e)); + }); + } + + public async update(foldersManager: FolderRepositoryManager, issueModel: IssueModel): Promise { + this._folderRepositoryManager = foldersManager; + this._postMessage({ + command: 'set-scroll', + scrollPosition: this._scrollPosition, + }); + + this._panel.webview.html = this.getHtmlForWebview(issueModel.number.toString()); + return this.updateIssue(issueModel); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'alert': + vscode.window.showErrorMessage(message.args); + return; + case 'pr.close': + return this.close(message); + case 'pr.comment': + return this.createComment(message); + case 'scroll': + this._scrollPosition = message.args.scrollPosition; + return; + case 'pr.edit-comment': + return this.editComment(message); + case 'pr.delete-comment': + return this.deleteComment(message); + case 'pr.edit-description': + return this.editDescription(message); + case 'pr.edit-title': + return this.editTitle(message); + case 'pr.refresh': + this.refreshPanel(); + return; + case 'pr.add-labels': + return this.addLabels(message); + case 'pr.remove-label': + return this.removeLabel(message); + case 'pr.debug': + return this.webviewDebug(message); + default: + return this.MESSAGE_UNHANDLED; + } + } + + private async addLabels(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); + try { + let newLabels: ILabel[] = []; + + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.show(); + quickPick.items = await (getLabelOptions(this._folderRepositoryManager, this._item.item.labels, this._item.remote).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + })); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (labelsToAdd) { + await this._item.setLabels(labelsToAdd.map(r => r.label)); + const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); + + this._item.item.labels = addedLabels; + + await this._replyMessage(message, { + added: addedLabels, + }); + } + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); + } + } + + private async removeLabel(message: IRequestMessage): Promise { + try { + await this._item.removeLabel(message.args); + + const index = this._item.item.labels.findIndex(label => label.name === message.args); + this._item.item.labels.splice(index, 1); + + this._replyMessage(message, {}); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private webviewDebug(message: IRequestMessage): void { + Logger.debug(message.args, IssueOverviewPanel.ID); + } + + private editDescription(message: IRequestMessage<{ text: string }>) { + this._item + .edit({ body: message.args.text }) + .then(result => { + this._replyMessage(message, { body: result.body, bodyHTML: result.bodyHTML }); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`); + }); + } + private editTitle(message: IRequestMessage<{ text: string }>) { + return this._item + .edit({ title: message.args.text }) + .then(result => { + return this._replyMessage(message, { titleHTML: result.titleHTML }); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(`Editing title failed: ${formatError(e)}`); + }); + } + + protected editCommentPromise(comment: IComment, text: string): Promise { + return this._item.editIssueComment(comment, text); + } + + private editComment(message: IRequestMessage<{ comment: IComment; text: string }>) { + this.editCommentPromise(message.args.comment, message.args.text) + .then(result => { + this._replyMessage(message, { + body: result.body, + bodyHTML: result.bodyHTML, + }); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(formatError(e)); + }); + } + + protected deleteCommentPromise(comment: IComment): Promise { + return this._item.deleteIssueComment(comment.id.toString()); + } + + private deleteComment(message: IRequestMessage) { + vscode.window + .showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, 'Delete') + .then(value => { + if (value === 'Delete') { + this.deleteCommentPromise(message.args) + .then(_ => { + this._replyMessage(message, {}); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(formatError(e)); + }); + } + }); + } + + private close(message: IRequestMessage): void { + vscode.commands + .executeCommand('pr.close', this._item, message.args) + .then(comment => { + if (comment) { + this._replyMessage(message, { + value: comment, + }); + } else { + this._throwError(message, 'Close cancelled'); + } + }); + } + + private createComment(message: IRequestMessage) { + this._item.createIssueComment(message.args).then(comment => { + this._replyMessage(message, { + value: comment, + }); + }); + } + + protected set _currentPanel(panel: IssueOverviewPanel | undefined) { + IssueOverviewPanel.currentPanel = panel; + } + + public dispose() { + this._currentPanel = undefined; + + // Clean up our resources + this._panel.dispose(); + this._webview = undefined; + + while (this._disposables.length) { + const x = this._disposables.pop(); + if (x) { + x.dispose(); + } + } + } + + protected getHtmlForWebview(number: string) { + const nonce = getNonce(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); + + return ` + + + + + + + Pull Request #${number} + + +
+ + +`; + } + + public getCurrentTitle(): string { + return this._panel.title; + } +} diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts index 56a98e33d5..72f531d01e 100644 --- a/src/github/loggingOctokit.ts +++ b/src/github/loggingOctokit.ts @@ -1,141 +1,142 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Octokit } from '@octokit/rest'; -import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost'; -import { bulkhead, BulkheadPolicy } from 'cockatiel'; -import * as vscode from 'vscode'; -import Logger from '../common/logger'; -import { ITelemetry } from '../common/telemetry'; -import { RateLimit } from './graphql'; - -interface RestResponse { - headers: { - 'x-ratelimit-limit': string; - 'x-ratelimit-remaining': string; - } -} - -export class RateLogger { - private bulkhead: BulkheadPolicy = bulkhead(140); - private static ID = 'RateLimit'; - private hasLoggedLowRateLimit: boolean = false; - - constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) { } - - public logAndLimit(info: string | undefined, apiRequest: () => Promise): Promise | undefined { - if (this.bulkhead.executionSlots === 0) { - Logger.error('API call count has exceeded 140 concurrent calls.', RateLogger.ID); - // We have hit more than 140 concurrent API requests. - /* __GDPR__ - "pr.highApiCallRate" : {} - */ - this.telemetry.sendTelemetryErrorEvent('pr.highApiCallRate'); - - if (!this.errorOnFlood) { - // We don't want to error on flood, so try to execute the API request anyway. - return apiRequest(); - } else { - vscode.window.showErrorMessage(vscode.l10n.t('The GitHub Pull Requests extension is making too many requests to GitHub. This indicates a bug in the extension. Please file an issue on GitHub and include the output from "GitHub Pull Request".')); - return undefined; - } - } - const log = `Extension rate limit remaining: ${this.bulkhead.executionSlots}, ${info}`; - if (this.bulkhead.executionSlots < 5) { - Logger.appendLine(log, RateLogger.ID); - } else { - Logger.debug(log, RateLogger.ID); - } - - return this.bulkhead.execute(() => apiRequest()); - } - - public async logRateLimit(info: string | undefined, result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) { - let rateLimitInfo; - try { - const resolvedResult = await result; - rateLimitInfo = resolvedResult?.data?.rateLimit; - } catch (e) { - // Ignore errors here since we're just trying to log the rate limit. - return; - } - const isSearch = info?.startsWith('/search/'); - if ((rateLimitInfo?.limit ?? 5000) < 5000) { - if (!isSearch) { - Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); - } else if (rateLimitInfo.limit < 30) { - Logger.appendLine(`Unexpectedly low SEARCH rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); - } - } - const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}, ${info}`; - if (((rateLimitInfo?.remaining ?? 1000) < 1000) && !isSearch) { - if (!this.hasLoggedLowRateLimit) { - /* __GDPR__ - "pr.lowRateLimitRemaining" : {} - */ - this.telemetry.sendTelemetryErrorEvent('pr.lowRateLimitRemaining'); - this.hasLoggedLowRateLimit = true; - } - Logger.warn(remaining, RateLogger.ID); - } else { - Logger.debug(remaining, RateLogger.ID); - } - } - - public async logRestRateLimit(info: string | undefined, restResponse: Promise) { - let result; - try { - result = await restResponse; - } catch (e) { - // Ignore errors here since we're just trying to log the rate limit. - return; - } - const rateLimit: RateLimit = { - cost: -1, - limit: Number(result.headers['x-ratelimit-limit']), - remaining: Number(result.headers['x-ratelimit-remaining']), - resetAt: '' - }; - this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true); - } -} - -export class LoggingApolloClient { - constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { } - - query(options: QueryOptions): Promise> { - const logInfo = (options.query.definitions[0] as { name: { value: string } | undefined }).name?.value; - const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.query(options)); - if (result === undefined) { - throw new Error('API call count has exceeded a rate limit.'); - } - this._rateLogger.logRateLimit(logInfo, result as any); - return result; - } - - mutate(options: MutationOptions): Promise> { - const logInfo = options.context; - const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.mutate(options)); - if (result === undefined) { - throw new Error('API call count has exceeded a rate limit.'); - } - this._rateLogger.logRateLimit(logInfo, result as any); - return result; - } -} - -export class LoggingOctokit { - constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { } - - async call(api: (T) => Promise, args: T): Promise { - const logInfo = (api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url; - const result = this._rateLogger.logAndLimit(logInfo, () => api(args)); - if (result === undefined) { - throw new Error('API call count has exceeded a rate limit.'); - } - this._rateLogger.logRestRateLimit(logInfo, result as Promise as Promise); - return result; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Octokit } from '@octokit/rest'; +import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost'; +import { bulkhead, BulkheadPolicy } from 'cockatiel'; +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { RateLimit } from './graphql'; + +interface RestResponse { + headers: { + 'x-ratelimit-limit': string; + 'x-ratelimit-remaining': string; + } +} + +export class RateLogger { + private bulkhead: BulkheadPolicy = bulkhead(140); + private static ID = 'RateLimit'; + private hasLoggedLowRateLimit: boolean = false; + + constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) { } + + public logAndLimit(info: string | undefined, apiRequest: () => Promise): Promise | undefined { + if (this.bulkhead.executionSlots === 0) { + Logger.error('API call count has exceeded 140 concurrent calls.', RateLogger.ID); + // We have hit more than 140 concurrent API requests. + /* __GDPR__ + "pr.highApiCallRate" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.highApiCallRate'); + + if (!this.errorOnFlood) { + // We don't want to error on flood, so try to execute the API request anyway. + return apiRequest(); + } else { + vscode.window.showErrorMessage(vscode.l10n.t('The GitHub Pull Requests extension is making too many requests to GitHub. This indicates a bug in the extension. Please file an issue on GitHub and include the output from "GitHub Pull Request".')); + return undefined; + } + } + const log = `Extension rate limit remaining: ${this.bulkhead.executionSlots}, ${info}`; + if (this.bulkhead.executionSlots < 5) { + Logger.appendLine(log, RateLogger.ID); + } else { + Logger.debug(log, RateLogger.ID); + } + + return this.bulkhead.execute(() => apiRequest()); + } + + public async logRateLimit(info: string | undefined, result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) { + let rateLimitInfo; + try { + const resolvedResult = await result; + rateLimitInfo = resolvedResult?.data?.rateLimit; + } catch (e) { + // Ignore errors here since we're just trying to log the rate limit. + return; + } + const isSearch = info?.startsWith('/search/'); + if ((rateLimitInfo?.limit ?? 5000) < 5000) { + if (!isSearch) { + Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } else if (rateLimitInfo.limit < 30) { + Logger.appendLine(`Unexpectedly low SEARCH rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } + } + const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}, ${info}`; + if (((rateLimitInfo?.remaining ?? 1000) < 1000) && !isSearch) { + if (!this.hasLoggedLowRateLimit) { + /* __GDPR__ + "pr.lowRateLimitRemaining" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.lowRateLimitRemaining'); + this.hasLoggedLowRateLimit = true; + } + Logger.warn(remaining, RateLogger.ID); + } else { + Logger.debug(remaining, RateLogger.ID); + } + } + + public async logRestRateLimit(info: string | undefined, restResponse: Promise) { + let result; + try { + result = await restResponse; + } catch (e) { + // Ignore errors here since we're just trying to log the rate limit. + return; + } + const rateLimit: RateLimit = { + cost: -1, + limit: Number(result.headers['x-ratelimit-limit']), + remaining: Number(result.headers['x-ratelimit-remaining']), + resetAt: '' + }; + this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true); + } +} + +export class LoggingApolloClient { + constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { } + + query(options: QueryOptions): Promise> { + const logInfo = (options.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.query(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as any); + return result; + } + + mutate(options: MutationOptions): Promise> { + const logInfo = options.context; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.mutate(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as any); + return result; + } +} + +export class LoggingOctokit { + constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { } + + async call(api: (T) => Promise, args: T): Promise { + const logInfo = (api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url; + const result = this._rateLogger.logAndLimit(logInfo, () => api(args)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRestRateLimit(logInfo, result as Promise as Promise); + return result; + } +} diff --git a/src/github/milestoneModel.ts b/src/github/milestoneModel.ts index c0884a1b65..355059444c 100644 --- a/src/github/milestoneModel.ts +++ b/src/github/milestoneModel.ts @@ -1,12 +1,13 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IMilestone } from './interface'; -import { IssueModel } from './issueModel'; - -export interface MilestoneModel { - milestone: IMilestone; - issues: IssueModel[]; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { IMilestone } from './interface'; +import { IssueModel } from './issueModel'; + +export interface MilestoneModel { + milestone: IMilestone; + issues: IssueModel[]; +} diff --git a/src/github/notifications.ts b/src/github/notifications.ts index 6d4122c0c4..da15b97ca8 100644 --- a/src/github/notifications.ts +++ b/src/github/notifications.ts @@ -1,399 +1,400 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { OctokitResponse } from '@octokit/types'; -import * as vscode from 'vscode'; -import { AuthProvider } from '../common/authentication'; -import Logger from '../common/logger'; -import { NOTIFICATION_SETTING, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { createPRNodeUri } from '../common/uri'; -import { PullRequestsTreeDataProvider } from '../view/prsTreeDataProvider'; -import { CategoryTreeNode } from '../view/treeNodes/categoryNode'; -import { PRNode } from '../view/treeNodes/pullRequestNode'; -import { TreeNode } from '../view/treeNodes/treeNode'; -import { CredentialStore, GitHub } from './credentials'; -import { GitHubRepository } from './githubRepository'; -import { PullRequestState } from './graphql'; -import { PullRequestModel } from './pullRequestModel'; -import { RepositoriesManager } from './repositoriesManager'; -import { hasEnterpriseUri } from './utils'; - -const DEFAULT_POLLING_DURATION = 60; - -export class Notification { - public readonly identifier; - public readonly threadId: number; - public readonly repositoryName: string; - public readonly pullRequestNumber: number; - public pullRequestModel?: PullRequestModel; - - constructor(identifier: string, threadId: number, repositoryName: string, - pullRequestNumber: number, pullRequestModel?: PullRequestModel) { - - this.identifier = identifier; - this.threadId = threadId; - this.repositoryName = repositoryName; - this.pullRequestNumber = pullRequestNumber; - this.pullRequestModel = pullRequestModel; - } -} - -export class NotificationProvider implements vscode.Disposable { - private static ID = 'NotificationProvider'; - private readonly _gitHubPrsTree: PullRequestsTreeDataProvider; - private readonly _credentialStore: CredentialStore; - private _authProvider: AuthProvider | undefined; - // The key uniquely identifies a PR from a Repository. The key is created with `getPrIdentifier` - private _notifications: Map; - private readonly _reposManager: RepositoriesManager; - - private _pollingDuration: number; - private _lastModified: string; - private _pollingHandler: NodeJS.Timeout | null; - - private disposables: vscode.Disposable[] = []; - - private _onDidChangeNotifications: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeNotifications = this._onDidChangeNotifications.event; - - constructor( - gitHubPrsTree: PullRequestsTreeDataProvider, - credentialStore: CredentialStore, - reposManager: RepositoriesManager - ) { - this._gitHubPrsTree = gitHubPrsTree; - this._credentialStore = credentialStore; - this._reposManager = reposManager; - this._notifications = new Map(); - - this._lastModified = ''; - this._pollingDuration = DEFAULT_POLLING_DURATION; - this._pollingHandler = null; - - this.registerAuthProvider(credentialStore); - - for (const manager of this._reposManager.folderManagers) { - this.disposables.push( - manager.onDidChangeGithubRepositories(() => { - this.refreshOrLaunchPolling(); - }) - ); - } - - this.disposables.push( - gitHubPrsTree.onDidChangeTreeData((node) => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(node); - } - }) - ); - this.disposables.push( - gitHubPrsTree.onDidChange(() => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(); - } - }) - ); - - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { - this.checkNotificationSetting(); - } - }) - ); - } - - private static isPRNotificationsOn() { - return ( - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === - 'pullRequests' - ); - } - - private registerAuthProvider(credentialStore: CredentialStore) { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } else if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - - this.disposables.push( - vscode.authentication.onDidChangeSessions(_ => { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } - - if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - }) - ); - } - - private getPrIdentifier(pullRequest: PullRequestModel | OctokitResponse['data']): string { - if (pullRequest instanceof PullRequestModel) { - return `${pullRequest.remote.url}:${pullRequest.number}`; - } - const splitPrUrl = pullRequest.subject.url.split('/'); - const prNumber = splitPrUrl[splitPrUrl.length - 1]; - return `${pullRequest.repository.html_url}.git:${prNumber}`; - } - - /* Takes a PullRequestModel or a PRIdentifier and - returns true if there is a Notification for the corresponding PR */ - public hasNotification(pullRequest: PullRequestModel | string): boolean { - const identifier = pullRequest instanceof PullRequestModel ? - this.getPrIdentifier(pullRequest) : - pullRequest; - const prNotifications = this._notifications.get(identifier); - return prNotifications !== undefined && prNotifications.length > 0; - } - - private updateViewBadge() { - const treeView = this._gitHubPrsTree.view; - const singularMessage = vscode.l10n.t('1 notification'); - const pluralMessage = vscode.l10n.t('{0} notifications', this._notifications.size); - treeView.badge = this._notifications.size !== 0 ? { - tooltip: this._notifications.size === 1 ? singularMessage : pluralMessage, - value: this._notifications.size - } : undefined; - } - - private adaptPRNotifications(node: TreeNode | void) { - if (this._pollingHandler === undefined) { - this.startPolling(); - } - - if (node instanceof PRNode) { - const prNotifications = this._notifications.get(this.getPrIdentifier(node.pullRequestModel)); - if (prNotifications) { - for (const prNotification of prNotifications) { - if (prNotification) { - prNotification.pullRequestModel = node.pullRequestModel; - return; - } - } - } - } - - this._gitHubPrsTree.cachedChildren().then(async (catNodes: CategoryTreeNode[]) => { - let allPrs: PullRequestModel[] = []; - - for (const catNode of catNodes) { - if (catNode.id === 'All Open') { - if (catNode.prs.length === 0) { - for (const prNode of await catNode.cachedChildren()) { - if (prNode instanceof PRNode) { - allPrs.push(prNode.pullRequestModel); - } - } - } - else { - allPrs = catNode.prs; - } - - } - } - - allPrs.forEach((pr) => { - const prNotifications = this._notifications.get(this.getPrIdentifier(pr)); - if (prNotifications) { - for (const prNotification of prNotifications) { - prNotification.pullRequestModel = pr; - } - } - }); - }); - } - - public refreshOrLaunchPolling() { - this._lastModified = ''; - this.checkNotificationSetting(); - } - - private checkNotificationSetting() { - const notificationsTurnedOn = NotificationProvider.isPRNotificationsOn(); - if (notificationsTurnedOn && this._pollingHandler === null) { - this.startPolling(); - } - else if (!notificationsTurnedOn && this._pollingHandler !== null) { - clearInterval(this._pollingHandler); - this._lastModified = ''; - this._pollingHandler = null; - this._pollingDuration = DEFAULT_POLLING_DURATION; - - this._onDidChangeNotifications.fire(this.uriFromNotifications()); - this._notifications.clear(); - this.updateViewBadge(); - } - } - - private uriFromNotifications(): vscode.Uri[] { - const notificationUris: vscode.Uri[] = []; - for (const [identifier, prNotifications] of this._notifications.entries()) { - if (prNotifications.length) { - notificationUris.push(createPRNodeUri(identifier)); - } - } - return notificationUris; - } - - private getGitHub(): GitHub | undefined { - return (this._authProvider !== undefined) ? - this._credentialStore.getHub(this._authProvider) : - undefined; - } - - private async getNotifications() { - const gitHub = this.getGitHub(); - if (gitHub === undefined) - return undefined; - const { data, headers } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, {}); - return { data: data, headers: headers }; - } - - private async markNotificationThreadAsRead(thredId) { - const github = this.getGitHub(); - if (!github) { - return; - } - await github.octokit.call(github.octokit.api.activity.markThreadAsRead, { - thread_id: thredId - }); - } - - public async markPrNotificationsAsRead(pullRequestModel: PullRequestModel) { - const identifier = this.getPrIdentifier(pullRequestModel); - const prNotifications = this._notifications.get(identifier); - if (prNotifications && prNotifications.length) { - for (const notification of prNotifications) { - await this.markNotificationThreadAsRead(notification.threadId); - } - - const uris = this.uriFromNotifications(); - this._onDidChangeNotifications.fire(uris); - this._notifications.delete(identifier); - this.updateViewBadge(); - } - } - - private async pollForNewNotifications() { - const response = await this.getNotifications(); - if (response === undefined) { - return; - } - const { data, headers } = response; - const pollTimeSuggested = Number(headers['x-poll-interval']); - - // Adapt polling interval if it has changed. - if (pollTimeSuggested !== this._pollingDuration) { - this._pollingDuration = pollTimeSuggested; - if (this._pollingHandler && NotificationProvider.isPRNotificationsOn()) { - Logger.appendLine('Notifications: Clearing interval'); - clearInterval(this._pollingHandler); - Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`); - this.startPolling(); - } - } - - // Only update if the user has new notifications - if (this._lastModified === headers['last-modified']) { - return; - } - this._lastModified = headers['last-modified'] ?? ''; - - const prNodesToUpdate = this.uriFromNotifications(); - this._notifications.clear(); - - const currentRepos = new Map(); - - this._reposManager.folderManagers.forEach(manager => { - manager.gitHubRepositories.forEach(repo => { - currentRepos.set(repo.remote.url, repo); - }); - }); - - await Promise.all(data.map(async (notification) => { - - const repoUrl = `${notification.repository.html_url}.git`; - const githubRepo = currentRepos.get(repoUrl); - - if (githubRepo && notification.subject.type === 'PullRequest') { - const splitPrUrl = notification.subject.url.split('/'); - const prNumber = Number(splitPrUrl[splitPrUrl.length - 1]); - const identifier = this.getPrIdentifier(notification); - - const { remote, query, schema } = await githubRepo.ensure(); - - const { data } = await query({ - query: schema.PullRequestState, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: prNumber, - }, - }); - - if (data.repository === null) { - Logger.error('Unexpected null repository when getting notifications', NotificationProvider.ID); - } - - // We only consider open PullRequests as these are displayed in the AllOpen PR category. - // Other categories could have queries with closed PRs, but its hard to figure out if a PR - // belongs to a query without loading each PR of that query. - if (data.repository?.pullRequest.state === 'OPEN') { - - const newNotification = new Notification( - identifier, - Number(notification.id), - notification.repository.name, - Number(prNumber) - ); - - const currentPrNotifications = this._notifications.get(identifier); - if (currentPrNotifications === undefined) { - this._notifications.set( - identifier, [newNotification] - ); - } - else { - currentPrNotifications.push(newNotification); - } - } - - } - })); - - this.adaptPRNotifications(); - - this.updateViewBadge(); - for (const uri of this.uriFromNotifications()) { - if (prNodesToUpdate.find(u => u.fsPath === uri.fsPath) === undefined) { - prNodesToUpdate.push(uri); - } - } - - this._onDidChangeNotifications.fire(prNodesToUpdate); - } - - private startPolling() { - this.pollForNewNotifications(); - this._pollingHandler = setInterval( - function (notificationProvider: NotificationProvider) { - notificationProvider.pollForNewNotifications(); - }, - this._pollingDuration * 1000, - this - ); - } - - public dispose() { - if (this._pollingHandler) { - clearInterval(this._pollingHandler); - } - this.disposables.forEach(displosable => displosable.dispose()); - } -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { OctokitResponse } from '@octokit/types'; +import * as vscode from 'vscode'; +import { AuthProvider } from '../common/authentication'; +import Logger from '../common/logger'; +import { NOTIFICATION_SETTING, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { createPRNodeUri } from '../common/uri'; +import { PullRequestsTreeDataProvider } from '../view/prsTreeDataProvider'; +import { CategoryTreeNode } from '../view/treeNodes/categoryNode'; +import { PRNode } from '../view/treeNodes/pullRequestNode'; +import { TreeNode } from '../view/treeNodes/treeNode'; +import { CredentialStore, GitHub } from './credentials'; +import { GitHubRepository } from './githubRepository'; +import { PullRequestState } from './graphql'; +import { PullRequestModel } from './pullRequestModel'; +import { RepositoriesManager } from './repositoriesManager'; +import { hasEnterpriseUri } from './utils'; + +const DEFAULT_POLLING_DURATION = 60; + +export class Notification { + public readonly identifier; + public readonly threadId: number; + public readonly repositoryName: string; + public readonly pullRequestNumber: number; + public pullRequestModel?: PullRequestModel; + + constructor(identifier: string, threadId: number, repositoryName: string, + pullRequestNumber: number, pullRequestModel?: PullRequestModel) { + + this.identifier = identifier; + this.threadId = threadId; + this.repositoryName = repositoryName; + this.pullRequestNumber = pullRequestNumber; + this.pullRequestModel = pullRequestModel; + } +} + +export class NotificationProvider implements vscode.Disposable { + private static ID = 'NotificationProvider'; + private readonly _gitHubPrsTree: PullRequestsTreeDataProvider; + private readonly _credentialStore: CredentialStore; + private _authProvider: AuthProvider | undefined; + // The key uniquely identifies a PR from a Repository. The key is created with `getPrIdentifier` + private _notifications: Map; + private readonly _reposManager: RepositoriesManager; + + private _pollingDuration: number; + private _lastModified: string; + private _pollingHandler: NodeJS.Timeout | null; + + private disposables: vscode.Disposable[] = []; + + private _onDidChangeNotifications: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChangeNotifications = this._onDidChangeNotifications.event; + + constructor( + gitHubPrsTree: PullRequestsTreeDataProvider, + credentialStore: CredentialStore, + reposManager: RepositoriesManager + ) { + this._gitHubPrsTree = gitHubPrsTree; + this._credentialStore = credentialStore; + this._reposManager = reposManager; + this._notifications = new Map(); + + this._lastModified = ''; + this._pollingDuration = DEFAULT_POLLING_DURATION; + this._pollingHandler = null; + + this.registerAuthProvider(credentialStore); + + for (const manager of this._reposManager.folderManagers) { + this.disposables.push( + manager.onDidChangeGithubRepositories(() => { + this.refreshOrLaunchPolling(); + }) + ); + } + + this.disposables.push( + gitHubPrsTree.onDidChangeTreeData((node) => { + if (NotificationProvider.isPRNotificationsOn()) { + this.adaptPRNotifications(node); + } + }) + ); + this.disposables.push( + gitHubPrsTree.onDidChange(() => { + if (NotificationProvider.isPRNotificationsOn()) { + this.adaptPRNotifications(); + } + }) + ); + + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { + this.checkNotificationSetting(); + } + }) + ); + } + + private static isPRNotificationsOn() { + return ( + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === + 'pullRequests' + ); + } + + private registerAuthProvider(credentialStore: CredentialStore) { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } else if (credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + + this.disposables.push( + vscode.authentication.onDidChangeSessions(_ => { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } + + if (credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + }) + ); + } + + private getPrIdentifier(pullRequest: PullRequestModel | OctokitResponse['data']): string { + if (pullRequest instanceof PullRequestModel) { + return `${pullRequest.remote.url}:${pullRequest.number}`; + } + const splitPrUrl = pullRequest.subject.url.split('/'); + const prNumber = splitPrUrl[splitPrUrl.length - 1]; + return `${pullRequest.repository.html_url}.git:${prNumber}`; + } + + /* Takes a PullRequestModel or a PRIdentifier and + returns true if there is a Notification for the corresponding PR */ + public hasNotification(pullRequest: PullRequestModel | string): boolean { + const identifier = pullRequest instanceof PullRequestModel ? + this.getPrIdentifier(pullRequest) : + pullRequest; + const prNotifications = this._notifications.get(identifier); + return prNotifications !== undefined && prNotifications.length > 0; + } + + private updateViewBadge() { + const treeView = this._gitHubPrsTree.view; + const singularMessage = vscode.l10n.t('1 notification'); + const pluralMessage = vscode.l10n.t('{0} notifications', this._notifications.size); + treeView.badge = this._notifications.size !== 0 ? { + tooltip: this._notifications.size === 1 ? singularMessage : pluralMessage, + value: this._notifications.size + } : undefined; + } + + private adaptPRNotifications(node: TreeNode | void) { + if (this._pollingHandler === undefined) { + this.startPolling(); + } + + if (node instanceof PRNode) { + const prNotifications = this._notifications.get(this.getPrIdentifier(node.pullRequestModel)); + if (prNotifications) { + for (const prNotification of prNotifications) { + if (prNotification) { + prNotification.pullRequestModel = node.pullRequestModel; + return; + } + } + } + } + + this._gitHubPrsTree.cachedChildren().then(async (catNodes: CategoryTreeNode[]) => { + let allPrs: PullRequestModel[] = []; + + for (const catNode of catNodes) { + if (catNode.id === 'All Open') { + if (catNode.prs.length === 0) { + for (const prNode of await catNode.cachedChildren()) { + if (prNode instanceof PRNode) { + allPrs.push(prNode.pullRequestModel); + } + } + } + else { + allPrs = catNode.prs; + } + + } + } + + allPrs.forEach((pr) => { + const prNotifications = this._notifications.get(this.getPrIdentifier(pr)); + if (prNotifications) { + for (const prNotification of prNotifications) { + prNotification.pullRequestModel = pr; + } + } + }); + }); + } + + public refreshOrLaunchPolling() { + this._lastModified = ''; + this.checkNotificationSetting(); + } + + private checkNotificationSetting() { + const notificationsTurnedOn = NotificationProvider.isPRNotificationsOn(); + if (notificationsTurnedOn && this._pollingHandler === null) { + this.startPolling(); + } + else if (!notificationsTurnedOn && this._pollingHandler !== null) { + clearInterval(this._pollingHandler); + this._lastModified = ''; + this._pollingHandler = null; + this._pollingDuration = DEFAULT_POLLING_DURATION; + + this._onDidChangeNotifications.fire(this.uriFromNotifications()); + this._notifications.clear(); + this.updateViewBadge(); + } + } + + private uriFromNotifications(): vscode.Uri[] { + const notificationUris: vscode.Uri[] = []; + for (const [identifier, prNotifications] of this._notifications.entries()) { + if (prNotifications.length) { + notificationUris.push(createPRNodeUri(identifier)); + } + } + return notificationUris; + } + + private getGitHub(): GitHub | undefined { + return (this._authProvider !== undefined) ? + this._credentialStore.getHub(this._authProvider) : + undefined; + } + + private async getNotifications() { + const gitHub = this.getGitHub(); + if (gitHub === undefined) + return undefined; + const { data, headers } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, {}); + return { data: data, headers: headers }; + } + + private async markNotificationThreadAsRead(thredId) { + const github = this.getGitHub(); + if (!github) { + return; + } + await github.octokit.call(github.octokit.api.activity.markThreadAsRead, { + thread_id: thredId + }); + } + + public async markPrNotificationsAsRead(pullRequestModel: PullRequestModel) { + const identifier = this.getPrIdentifier(pullRequestModel); + const prNotifications = this._notifications.get(identifier); + if (prNotifications && prNotifications.length) { + for (const notification of prNotifications) { + await this.markNotificationThreadAsRead(notification.threadId); + } + + const uris = this.uriFromNotifications(); + this._onDidChangeNotifications.fire(uris); + this._notifications.delete(identifier); + this.updateViewBadge(); + } + } + + private async pollForNewNotifications() { + const response = await this.getNotifications(); + if (response === undefined) { + return; + } + const { data, headers } = response; + const pollTimeSuggested = Number(headers['x-poll-interval']); + + // Adapt polling interval if it has changed. + if (pollTimeSuggested !== this._pollingDuration) { + this._pollingDuration = pollTimeSuggested; + if (this._pollingHandler && NotificationProvider.isPRNotificationsOn()) { + Logger.appendLine('Notifications: Clearing interval'); + clearInterval(this._pollingHandler); + Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`); + this.startPolling(); + } + } + + // Only update if the user has new notifications + if (this._lastModified === headers['last-modified']) { + return; + } + this._lastModified = headers['last-modified'] ?? ''; + + const prNodesToUpdate = this.uriFromNotifications(); + this._notifications.clear(); + + const currentRepos = new Map(); + + this._reposManager.folderManagers.forEach(manager => { + manager.gitHubRepositories.forEach(repo => { + currentRepos.set(repo.remote.url, repo); + }); + }); + + await Promise.all(data.map(async (notification) => { + + const repoUrl = `${notification.repository.html_url}.git`; + const githubRepo = currentRepos.get(repoUrl); + + if (githubRepo && notification.subject.type === 'PullRequest') { + const splitPrUrl = notification.subject.url.split('/'); + const prNumber = Number(splitPrUrl[splitPrUrl.length - 1]); + const identifier = this.getPrIdentifier(notification); + + const { remote, query, schema } = await githubRepo.ensure(); + + const { data } = await query({ + query: schema.PullRequestState, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: prNumber, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting notifications', NotificationProvider.ID); + } + + // We only consider open PullRequests as these are displayed in the AllOpen PR category. + // Other categories could have queries with closed PRs, but its hard to figure out if a PR + // belongs to a query without loading each PR of that query. + if (data.repository?.pullRequest.state === 'OPEN') { + + const newNotification = new Notification( + identifier, + Number(notification.id), + notification.repository.name, + Number(prNumber) + ); + + const currentPrNotifications = this._notifications.get(identifier); + if (currentPrNotifications === undefined) { + this._notifications.set( + identifier, [newNotification] + ); + } + else { + currentPrNotifications.push(newNotification); + } + } + + } + })); + + this.adaptPRNotifications(); + + this.updateViewBadge(); + for (const uri of this.uriFromNotifications()) { + if (prNodesToUpdate.find(u => u.fsPath === uri.fsPath) === undefined) { + prNodesToUpdate.push(uri); + } + } + + this._onDidChangeNotifications.fire(prNodesToUpdate); + } + + private startPolling() { + this.pollForNewNotifications(); + this._pollingHandler = setInterval( + function (notificationProvider: NotificationProvider) { + notificationProvider.pollForNewNotifications(); + }, + this._pollingDuration * 1000, + this + ); + } + + public dispose() { + if (this._pollingHandler) { + clearInterval(this._pollingHandler); + } + this.disposables.forEach(displosable => displosable.dispose()); + } +} diff --git a/src/github/prComment.ts b/src/github/prComment.ts index a57a5b742f..60cdb17e9b 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -1,402 +1,403 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import { DataUri } from '../common/uri'; -import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; -import { stringReplaceAsync } from '../common/utils'; -import { GitHubRepository } from './githubRepository'; -import { IAccount } from './interface'; -import { updateCommentReactions } from './utils'; - -export interface GHPRCommentThread extends vscode.CommentThread2 { - gitHubThreadId: string; - - /** - * The uri of the document the thread has been created on. - */ - uri: vscode.Uri; - - /** - * The range the comment thread is located within the document. The thread icon will be shown - * at the first line of the range. - */ - range: vscode.Range | undefined; - - /** - * The ordered comments of the thread. - */ - comments: (GHPRComment | TemporaryComment)[]; - - /** - * Whether the thread should be collapsed or expanded when opening the document. - * Defaults to Collapsed. - */ - collapsibleState: vscode.CommentThreadCollapsibleState; - - /** - * The optional human-readable label describing the [Comment Thread](#CommentThread) - */ - label?: string; - - /** - * Whether the thread has been marked as resolved. - */ - state: vscode.CommentThreadState; - - dispose: () => void; -} - -export namespace GHPRCommentThread { - export function is(value: any): value is GHPRCommentThread { - return (value && (typeof (value as GHPRCommentThread).gitHubThreadId) === 'string'); - } -} - -abstract class CommentBase implements vscode.Comment { - public abstract commentId: undefined | string; - - /** - * The comment thread the comment is from - */ - public parent: GHPRCommentThread; - - /** - * The text of the comment as from GitHub - */ - public abstract get body(): string | vscode.MarkdownString; - public abstract set body(body: string | vscode.MarkdownString); - - /** - * Whether the comment is in edit mode or not - */ - public mode: vscode.CommentMode; - - /** - * The author of the comment - */ - public author: vscode.CommentAuthorInformation; - - /** - * The label to display on the comment, 'Pending' or nothing - */ - public label: string | undefined; - - /** - * The list of reactions to the comment - */ - public reactions?: vscode.CommentReaction[] | undefined; - - /** - * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json - */ - public contextValue: string; - - constructor( - parent: GHPRCommentThread, - ) { - this.parent = parent; - } - - public abstract commentEditId(): number | string; - - startEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { - cmt.mode = vscode.CommentMode.Editing; - } - - return cmt; - }); - } - - protected abstract getCancelEditBody(): string | vscode.MarkdownString; - - cancelEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { - cmt.mode = vscode.CommentMode.Preview; - cmt.body = this.getCancelEditBody(); - } - - return cmt; - }); - } -} - -/** - * Used to optimistically render updates to comment threads. Temporary comments are immediately - * set when a command is run, and then replaced with real data when the operation finishes. - */ -export class TemporaryComment extends CommentBase { - public commentId: undefined; - - /** - * The id of the comment - */ - public id: number; - - /** - * If the temporary comment is in place for an edit, the original text value of the comment - */ - public originalBody?: string; - - static idPool = 0; - - constructor( - parent: GHPRCommentThread, - private input: string, - isDraft: boolean, - currentUser: IAccount, - originalComment?: GHPRComment, - ) { - super(parent); - this.mode = vscode.CommentMode.Preview; - this.author = { - name: currentUser.login, - iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, - }; - this.label = isDraft ? vscode.l10n.t('Pending') : undefined; - this.contextValue = 'temporary,canEdit,canDelete'; - this.originalBody = originalComment ? originalComment.rawComment.body : undefined; - this.reactions = originalComment ? originalComment.reactions : undefined; - this.id = TemporaryComment.idPool++; - } - - set body(input: string | vscode.MarkdownString) { - if (typeof input === 'string') { - this.input = input; - } - } - - get body(): string | vscode.MarkdownString { - return new vscode.MarkdownString(this.input); - } - - commentEditId() { - return this.id; - } - - protected getCancelEditBody() { - return this.originalBody || this.body; - } -} - -const SUGGESTION_EXPRESSION = /```suggestion(\r\n|\n)((?[\s\S]*?)(\r\n|\n))?```/; - -export class GHPRComment extends CommentBase { - public commentId: string; - public timestamp: Date; - - /** - * The complete comment data returned from GitHub - */ - public rawComment: IComment; - - private _rawBody: string | vscode.MarkdownString; - private replacedBody: string; - - constructor(context: vscode.ExtensionContext, comment: IComment, parent: GHPRCommentThread, private readonly githubRepository?: GitHubRepository) { - super(parent); - this.rawComment = comment; - this.body = comment.body; - this.commentId = comment.id.toString(); - this.author = { - name: comment.user!.login, - iconPath: comment.user && comment.user.avatarUrl ? vscode.Uri.parse(comment.user.avatarUrl) : undefined, - }; - if (comment.user) { - DataUri.avatarCirclesAsImageDataUris(context, [comment.user], 28, 28).then(avatarUris => { - this.author.iconPath = avatarUris[0]; - this.refresh(); - }); - } - - updateCommentReactions(this, comment.reactions); - - this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; - - const contextValues: string[] = []; - if (comment.canEdit) { - contextValues.push('canEdit'); - } - - if (comment.canDelete) { - contextValues.push('canDelete'); - } - - if (this.suggestion !== undefined) { - contextValues.push('hasSuggestion'); - } - - this.contextValue = contextValues.join(','); - this.timestamp = new Date(comment.createdAt); - } - - update(comment: IComment) { - const oldRawComment = this.rawComment; - this.rawComment = comment; - let refresh: boolean = false; - - if (updateCommentReactions(this, comment.reactions)) { - refresh = true; - } - - const oldLabel = this.label; - this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; - if (this.label !== oldLabel) { - refresh = true; - } - - const contextValues: string[] = []; - if (comment.canEdit) { - contextValues.push('canEdit'); - } - - if (comment.canDelete) { - contextValues.push('canDelete'); - } - - if (this.suggestion !== undefined) { - contextValues.push('hasSuggestion'); - } - - const oldContextValue = this.contextValue; - this.contextValue = contextValues.join(','); - if (oldContextValue !== this.contextValue) { - refresh = true; - } - - // Set the comment body last as it will trigger an update if set. - if (oldRawComment.body !== comment.body) { - this.body = comment.body; - refresh = false; - } - - if (refresh) { - this.refresh(); - } - } - - private refresh() { - // Self assign the comments to trigger an update of the comments in VS Code now that we have replaced the body. - // eslint-disable-next-line no-self-assign - this.parent.comments = this.parent.comments; - } - - get suggestion(): string | undefined { - const match = this.rawComment.body.match(SUGGESTION_EXPRESSION); - const suggestionBody = match?.groups?.suggestion; - if (match?.length === 5) { - return suggestionBody ? `${suggestionBody}\n` : ''; - } - } - - public commentEditId() { - return this.commentId; - } - - private replaceSuggestion(body: string) { - return body.replace(new RegExp(SUGGESTION_EXPRESSION, 'g'), (_substring: string, ...args: any[]) => { - return `*** -Suggested change: -\`\`\` -${args[2] ?? ''} -\`\`\` -***`; - }); - } - - private async createLocalFilePath(rootUri: vscode.Uri, fileSubPath: string, startLine: number, endLine: number): Promise { - const localFile = vscode.Uri.joinPath(rootUri, fileSubPath); - const stat = await vscode.workspace.fs.stat(localFile); - if (stat.type === vscode.FileType.File) { - return `${localFile.with({ fragment: `${startLine}-${endLine}` }).toString()}`; - } - } - - private async replacePermalink(body: string): Promise { - const githubRepository = this.githubRepository; - if (!githubRepository) { - return body; - } - - const expression = new RegExp(`https://github.com/${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}/blob/([0-9a-f]{40})/(.*)#L([0-9]+)(-L([0-9]+))?`, 'g'); - return stringReplaceAsync(body, expression, async (match: string, sha: string, file: string, start: string, _endGroup?: string, end?: string, index?: number) => { - if (index && (index > 0) && (body.charAt(index - 1) === '(')) { - return match; - } - const startLine = parseInt(start); - const endLine = end ? parseInt(end) : startLine + 1; - const lineContents = await githubRepository.getLines(sha, file, startLine, endLine); - if (!lineContents) { - return match; - } - const localFile = await this.createLocalFilePath(githubRepository.rootUri, file, startLine, endLine); - const lineMessage = end ? `Lines ${startLine} to ${endLine} in \`${sha.substring(0, 7)}\`` : `Line ${startLine} in \`${sha.substring(0, 7)}\``; - return ` -*** -[${file}](${localFile ?? match})${localFile ? ` ([view on GitHub](${match}))` : ''} - -${lineMessage} -\`\`\` -${lineContents} -\`\`\` -***`; - }); - } - - private replaceNewlines(body: string) { - return body.replace(/(? { - if (body instanceof vscode.MarkdownString) { - const permalinkReplaced = await this.replacePermalink(body.value); - return this.replaceSuggestion(permalinkReplaced); - } - const newLinesReplaced = this.replaceNewlines(body); - const documentLanguage = (await vscode.workspace.openTextDocument(this.parent.uri)).languageId; - // Replace user - const linkified = newLinesReplaced.replace(/([^\[`]|^)\@([^\s`]+)/g, (substring, _1, _2, offset) => { - // Do not try to replace user if there's a code block. - if ((newLinesReplaced.substring(0, offset).match(/```/g)?.length ?? 0) % 2 === 1) { - return substring; - } - const username = substring.substring(substring.startsWith('@') ? 1 : 2); - if ((((documentLanguage === 'javascript') || (documentLanguage === 'typescript')) && JSDOC_NON_USERS.includes(username)) - || ((documentLanguage === 'php') && PHPDOC_NON_USERS.includes(username))) { - return substring; - } - return `${substring.startsWith('@') ? '' : substring.charAt(0)}[@${username}](${path.dirname(this.rawComment.user!.url)}/${username})`; - }); - - const permalinkReplaced = await this.replacePermalink(linkified); - return this.replaceSuggestion(permalinkReplaced); - } - - set body(body: string | vscode.MarkdownString) { - this._rawBody = body; - this.replaceBody(body).then(replacedBody => { - if (replacedBody !== this.replacedBody) { - this.replacedBody = replacedBody; - this.refresh(); - } - }); - } - - get body(): string | vscode.MarkdownString { - if (this.mode === vscode.CommentMode.Editing) { - return this._rawBody; - } - return new vscode.MarkdownString(this.replacedBody); - } - - protected getCancelEditBody() { - return new vscode.MarkdownString(this.rawComment.body); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { IComment } from '../common/comment'; +import { DataUri } from '../common/uri'; +import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; +import { stringReplaceAsync } from '../common/utils'; +import { GitHubRepository } from './githubRepository'; +import { IAccount } from './interface'; +import { updateCommentReactions } from './utils'; + +export interface GHPRCommentThread extends vscode.CommentThread2 { + gitHubThreadId: string; + + /** + * The uri of the document the thread has been created on. + */ + uri: vscode.Uri; + + /** + * The range the comment thread is located within the document. The thread icon will be shown + * at the first line of the range. + */ + range: vscode.Range | undefined; + + /** + * The ordered comments of the thread. + */ + comments: (GHPRComment | TemporaryComment)[]; + + /** + * Whether the thread should be collapsed or expanded when opening the document. + * Defaults to Collapsed. + */ + collapsibleState: vscode.CommentThreadCollapsibleState; + + /** + * The optional human-readable label describing the [Comment Thread](#CommentThread) + */ + label?: string; + + /** + * Whether the thread has been marked as resolved. + */ + state: vscode.CommentThreadState; + + dispose: () => void; +} + +export namespace GHPRCommentThread { + export function is(value: any): value is GHPRCommentThread { + return (value && (typeof (value as GHPRCommentThread).gitHubThreadId) === 'string'); + } +} + +abstract class CommentBase implements vscode.Comment { + public abstract commentId: undefined | string; + + /** + * The comment thread the comment is from + */ + public parent: GHPRCommentThread; + + /** + * The text of the comment as from GitHub + */ + public abstract get body(): string | vscode.MarkdownString; + public abstract set body(body: string | vscode.MarkdownString); + + /** + * Whether the comment is in edit mode or not + */ + public mode: vscode.CommentMode; + + /** + * The author of the comment + */ + public author: vscode.CommentAuthorInformation; + + /** + * The label to display on the comment, 'Pending' or nothing + */ + public label: string | undefined; + + /** + * The list of reactions to the comment + */ + public reactions?: vscode.CommentReaction[] | undefined; + + /** + * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json + */ + public contextValue: string; + + constructor( + parent: GHPRCommentThread, + ) { + this.parent = parent; + } + + public abstract commentEditId(): number | string; + + startEdit() { + this.parent.comments = this.parent.comments.map(cmt => { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { + cmt.mode = vscode.CommentMode.Editing; + } + + return cmt; + }); + } + + protected abstract getCancelEditBody(): string | vscode.MarkdownString; + + cancelEdit() { + this.parent.comments = this.parent.comments.map(cmt => { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { + cmt.mode = vscode.CommentMode.Preview; + cmt.body = this.getCancelEditBody(); + } + + return cmt; + }); + } +} + +/** + * Used to optimistically render updates to comment threads. Temporary comments are immediately + * set when a command is run, and then replaced with real data when the operation finishes. + */ +export class TemporaryComment extends CommentBase { + public commentId: undefined; + + /** + * The id of the comment + */ + public id: number; + + /** + * If the temporary comment is in place for an edit, the original text value of the comment + */ + public originalBody?: string; + + static idPool = 0; + + constructor( + parent: GHPRCommentThread, + private input: string, + isDraft: boolean, + currentUser: IAccount, + originalComment?: GHPRComment, + ) { + super(parent); + this.mode = vscode.CommentMode.Preview; + this.author = { + name: currentUser.login, + iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, + }; + this.label = isDraft ? vscode.l10n.t('Pending') : undefined; + this.contextValue = 'temporary,canEdit,canDelete'; + this.originalBody = originalComment ? originalComment.rawComment.body : undefined; + this.reactions = originalComment ? originalComment.reactions : undefined; + this.id = TemporaryComment.idPool++; + } + + set body(input: string | vscode.MarkdownString) { + if (typeof input === 'string') { + this.input = input; + } + } + + get body(): string | vscode.MarkdownString { + return new vscode.MarkdownString(this.input); + } + + commentEditId() { + return this.id; + } + + protected getCancelEditBody() { + return this.originalBody || this.body; + } +} + +const SUGGESTION_EXPRESSION = /```suggestion(\r\n|\n)((?[\s\S]*?)(\r\n|\n))?```/; + +export class GHPRComment extends CommentBase { + public commentId: string; + public timestamp: Date; + + /** + * The complete comment data returned from GitHub + */ + public rawComment: IComment; + + private _rawBody: string | vscode.MarkdownString; + private replacedBody: string; + + constructor(context: vscode.ExtensionContext, comment: IComment, parent: GHPRCommentThread, private readonly githubRepository?: GitHubRepository) { + super(parent); + this.rawComment = comment; + this.body = comment.body; + this.commentId = comment.id.toString(); + this.author = { + name: comment.user!.login, + iconPath: comment.user && comment.user.avatarUrl ? vscode.Uri.parse(comment.user.avatarUrl) : undefined, + }; + if (comment.user) { + DataUri.avatarCirclesAsImageDataUris(context, [comment.user], 28, 28).then(avatarUris => { + this.author.iconPath = avatarUris[0]; + this.refresh(); + }); + } + + updateCommentReactions(this, comment.reactions); + + this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + + const contextValues: string[] = []; + if (comment.canEdit) { + contextValues.push('canEdit'); + } + + if (comment.canDelete) { + contextValues.push('canDelete'); + } + + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + this.contextValue = contextValues.join(','); + this.timestamp = new Date(comment.createdAt); + } + + update(comment: IComment) { + const oldRawComment = this.rawComment; + this.rawComment = comment; + let refresh: boolean = false; + + if (updateCommentReactions(this, comment.reactions)) { + refresh = true; + } + + const oldLabel = this.label; + this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + if (this.label !== oldLabel) { + refresh = true; + } + + const contextValues: string[] = []; + if (comment.canEdit) { + contextValues.push('canEdit'); + } + + if (comment.canDelete) { + contextValues.push('canDelete'); + } + + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + const oldContextValue = this.contextValue; + this.contextValue = contextValues.join(','); + if (oldContextValue !== this.contextValue) { + refresh = true; + } + + // Set the comment body last as it will trigger an update if set. + if (oldRawComment.body !== comment.body) { + this.body = comment.body; + refresh = false; + } + + if (refresh) { + this.refresh(); + } + } + + private refresh() { + // Self assign the comments to trigger an update of the comments in VS Code now that we have replaced the body. + // eslint-disable-next-line no-self-assign + this.parent.comments = this.parent.comments; + } + + get suggestion(): string | undefined { + const match = this.rawComment.body.match(SUGGESTION_EXPRESSION); + const suggestionBody = match?.groups?.suggestion; + if (match?.length === 5) { + return suggestionBody ? `${suggestionBody}\n` : ''; + } + } + + public commentEditId() { + return this.commentId; + } + + private replaceSuggestion(body: string) { + return body.replace(new RegExp(SUGGESTION_EXPRESSION, 'g'), (_substring: string, ...args: any[]) => { + return `*** +Suggested change: +\`\`\` +${args[2] ?? ''} +\`\`\` +***`; + }); + } + + private async createLocalFilePath(rootUri: vscode.Uri, fileSubPath: string, startLine: number, endLine: number): Promise { + const localFile = vscode.Uri.joinPath(rootUri, fileSubPath); + const stat = await vscode.workspace.fs.stat(localFile); + if (stat.type === vscode.FileType.File) { + return `${localFile.with({ fragment: `${startLine}-${endLine}` }).toString()}`; + } + } + + private async replacePermalink(body: string): Promise { + const githubRepository = this.githubRepository; + if (!githubRepository) { + return body; + } + + const expression = new RegExp(`https://github.com/${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}/blob/([0-9a-f]{40})/(.*)#L([0-9]+)(-L([0-9]+))?`, 'g'); + return stringReplaceAsync(body, expression, async (match: string, sha: string, file: string, start: string, _endGroup?: string, end?: string, index?: number) => { + if (index && (index > 0) && (body.charAt(index - 1) === '(')) { + return match; + } + const startLine = parseInt(start); + const endLine = end ? parseInt(end) : startLine + 1; + const lineContents = await githubRepository.getLines(sha, file, startLine, endLine); + if (!lineContents) { + return match; + } + const localFile = await this.createLocalFilePath(githubRepository.rootUri, file, startLine, endLine); + const lineMessage = end ? `Lines ${startLine} to ${endLine} in \`${sha.substring(0, 7)}\`` : `Line ${startLine} in \`${sha.substring(0, 7)}\``; + return ` +*** +[${file}](${localFile ?? match})${localFile ? ` ([view on GitHub](${match}))` : ''} + +${lineMessage} +\`\`\` +${lineContents} +\`\`\` +***`; + }); + } + + private replaceNewlines(body: string) { + return body.replace(/(? { + if (body instanceof vscode.MarkdownString) { + const permalinkReplaced = await this.replacePermalink(body.value); + return this.replaceSuggestion(permalinkReplaced); + } + const newLinesReplaced = this.replaceNewlines(body); + const documentLanguage = (await vscode.workspace.openTextDocument(this.parent.uri)).languageId; + // Replace user + const linkified = newLinesReplaced.replace(/([^\[`]|^)\@([^\s`]+)/g, (substring, _1, _2, offset) => { + // Do not try to replace user if there's a code block. + if ((newLinesReplaced.substring(0, offset).match(/```/g)?.length ?? 0) % 2 === 1) { + return substring; + } + const username = substring.substring(substring.startsWith('@') ? 1 : 2); + if ((((documentLanguage === 'javascript') || (documentLanguage === 'typescript')) && JSDOC_NON_USERS.includes(username)) + || ((documentLanguage === 'php') && PHPDOC_NON_USERS.includes(username))) { + return substring; + } + return `${substring.startsWith('@') ? '' : substring.charAt(0)}[@${username}](${path.dirname(this.rawComment.user!.url)}/${username})`; + }); + + const permalinkReplaced = await this.replacePermalink(linkified); + return this.replaceSuggestion(permalinkReplaced); + } + + set body(body: string | vscode.MarkdownString) { + this._rawBody = body; + this.replaceBody(body).then(replacedBody => { + if (replacedBody !== this.replacedBody) { + this.replacedBody = replacedBody; + this.refresh(); + } + }); + } + + get body(): string | vscode.MarkdownString { + if (this.mode === vscode.CommentMode.Editing) { + return this._rawBody; + } + return new vscode.MarkdownString(this.replacedBody); + } + + protected getCancelEditBody() { + return new vscode.MarkdownString(this.rawComment.body); + } +} diff --git a/src/github/pullRequestGitHelper.ts b/src/github/pullRequestGitHelper.ts index b9e3a28546..0ce15d11e4 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -1,478 +1,479 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* - * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/165a97bdcab7559e0c4393a571b9ff2aed4ba8a7/src/GitHub.App/Services/PullRequestService.cs - */ -import * as vscode from 'vscode'; -import { Branch, Repository } from '../api/api'; -import { GitErrorCodes } from '../api/api1'; -import Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; - -const PullRequestRemoteMetadataKey = 'github-pr-remote'; -export const PullRequestMetadataKey = 'github-pr-owner-number'; -const BaseBranchMetadataKey = 'github-pr-base-branch'; -const PullRequestBranchRegex = /branch\.(.+)\.github-pr-owner-number/; -const PullRequestRemoteRegex = /branch\.(.+)\.remote/; - -export interface PullRequestMetadata { - owner: string; - repositoryName: string; - prNumber: number; -} - -export interface BaseBranchMetadata { - owner: string; - repositoryName: string; - branch: string; -} - -export class PullRequestGitHelper { - static ID = 'PullRequestGitHelper'; - static async checkoutFromFork( - repository: Repository, - pullRequest: PullRequestModel & IResolvedPullRequestModel, - remoteName: string | undefined, - progress: vscode.Progress<{ message?: string; increment?: number }> - ) { - // the branch is from a fork - const localBranchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); - - // create remote for this fork - if (!remoteName) { - Logger.appendLine( - `Branch ${localBranchName} is from a fork. Create a remote first.`, - PullRequestGitHelper.ID, - ); - progress.report({ message: vscode.l10n.t('Creating git remote for {0}', `${pullRequest.remote.owner}/${pullRequest.remote.repositoryName}`) }); - remoteName = await PullRequestGitHelper.createRemote( - repository, - pullRequest.remote, - pullRequest.head.repositoryCloneUrl, - ); - } - - // fetch the branch - const ref = `${pullRequest.head.ref}:${localBranchName}`; - Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - start`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Fetching branch {0}', ref) }); - await repository.fetch(remoteName, ref, 1); - Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - done`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Checking out {0}', ref) }); - await repository.checkout(localBranchName); - // set remote tracking branch for the local branch - await repository.setBranchUpstream(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); - // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. - this.unshallow(repository); - await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); - } - - static async fetchAndCheckout( - repository: Repository, - remotes: Remote[], - pullRequest: PullRequestModel, - progress: vscode.Progress<{ message?: string; increment?: number }> - ): Promise { - if (!pullRequest.validatePullRequestModel('Checkout pull request failed')) { - return; - } - - const remote = PullRequestGitHelper.getHeadRemoteForPullRequest(remotes, pullRequest); - const isFork = pullRequest.head.repositoryCloneUrl.owner !== pullRequest.base.repositoryCloneUrl.owner; - if (!remote || isFork) { - return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName, progress); - } - - const branchName = pullRequest.head.ref; - const remoteName = remote.remoteName; - let branch: Branch; - - try { - branch = await repository.getBranch(branchName); - // Make sure we aren't already on this branch - if (repository.state.HEAD?.name === branch.name) { - Logger.appendLine(`Tried to checkout ${branchName}, but branch is already checked out.`, PullRequestGitHelper.ID); - return; - } - Logger.debug(`Checkout ${branchName}`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Checking out {0}', branchName) }); - await repository.checkout(branchName); - - if (!branch.upstream) { - // this branch is not associated with upstream yet - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - await repository.setBranchUpstream(branchName, trackedBranchName); - } - - if (branch.behind !== undefined && branch.behind > 0 && branch.ahead === 0) { - Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Pulling {0}', branchName) }); - await repository.pull(); - } - } catch (err) { - // there is no local branch with the same name, so we are good to fetch, create and checkout the remote branch. - Logger.appendLine( - `Branch ${remoteName}/${branchName} doesn't exist on local disk yet.`, - PullRequestGitHelper.ID, - ); - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); - await repository.fetch(remoteName, branchName, 1); - const trackedBranch = await repository.getBranch(trackedBranchName); - // create branch - progress.report({ message: vscode.l10n.t('Creating and checking out branch {0}', branchName) }); - await repository.createBranch(branchName, true, trackedBranch.commit); - await repository.setBranchUpstream(branchName, trackedBranchName); - - // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. - this.unshallow(repository); - } - - await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, branchName); - } - - /** - * Attempt to unshallow the repository. If it has been unshallowed in the interim, running with `--unshallow` - * will fail, so fall back to a normal pull. - */ - static async unshallow(repository: Repository): Promise { - let error: Error & { gitErrorCode?: GitErrorCodes }; - try { - await repository.pull(true); - return; - } catch (e) { - Logger.appendLine(`Unshallowing failed: ${e}.`); - if (e.stderr && (e.stderr as string).includes('would clobber existing tag')) { - // ignore this error - return; - } - error = e; - } - try { - if (error.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - Logger.appendLine(`Getting status and trying unshallow again.`); - await repository.status(); - await repository.pull(true); - return; - } - } catch (e) { - Logger.appendLine(`Unshallowing still failed: ${e}.`); - } - try { - Logger.appendLine(`Falling back to git pull.`); - await repository.pull(false); - } catch (e) { - Logger.error(`Pull after failed unshallow still failed: ${e}`); - throw e; - } - } - - static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>) { - const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); - const configs = await repository.getConfigs(); - - const readConfig = (searchKey: string): string | undefined => - configs.filter(({ key: k }) => searchKey === k).map(({ value }) => value)[0]; - - const branchInfos = configs - .map(config => { - const matches = PullRequestBranchRegex.exec(config.key); - return { - branch: matches && matches.length ? matches[1] : null, - value: config.value, - }; - }) - .filter(c => c.branch && c.value === key); - - if (branchInfos && branchInfos.length) { - // let's immediately checkout to branchInfos[0].branch - const branchName = branchInfos[0].branch!; - progress.report({ message: vscode.l10n.t('Checking out branch {0}', branchName) }); - await repository.checkout(branchName); - - // respect the git setting to fetch before checkout - if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true)) { - const remote = readConfig(`branch.${branchName}.remote`); - const ref = readConfig(`branch.${branchName}.merge`); - progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); - await repository.fetch(remote, ref); - } - - const branchStatus = await repository.getBranch(branchInfos[0].branch!); - if (branchStatus.upstream === undefined) { - return false; - } - - if (branchStatus.behind !== undefined && branchStatus.behind > 0 && branchStatus.ahead === 0) { - Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Pulling branch {0}', branchName) }); - await repository.pull(); - } - - return true; - } else { - return false; - } - } - - static async getBranchNRemoteForPullRequest( - repository: Repository, - pullRequest: PullRequestModel, - ): Promise<{ - branch: string; - remote?: string; - createdForPullRequest?: boolean; - remoteInUse?: boolean; - } | null> { - const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); - const configs = await repository.getConfigs(); - - const branchInfo = configs - .map(config => { - const matches = PullRequestBranchRegex.exec(config.key); - return { - branch: matches && matches.length ? matches[1] : null, - value: config.value, - }; - }) - .find(c => !!c.branch && c.value === key); - - if (branchInfo) { - // we find the branch - const branchName = branchInfo.branch; - - try { - const configKey = `branch.${branchName}.remote`; - const branchRemotes = configs.filter(config => config.key === configKey).map(config => config.value); - let remoteName: string | undefined = undefined; - if (branchRemotes.length) { - remoteName = branchRemotes[0]; - } - - let createdForPullRequest = false; - if (remoteName) { - const remoteCreatedForPullRequestKey = `remote.${remoteName}.github-pr-remote`; - const remoteCreatedForPullRequest = configs.filter( - config => config.key === remoteCreatedForPullRequestKey && config.value, - ); - - if (remoteCreatedForPullRequest.length) { - // it's created for pull request - createdForPullRequest = true; - } - } - - let remoteInUse: boolean | undefined; - if (createdForPullRequest) { - // try to find other branches under this remote - remoteInUse = configs.some(config => { - const matches = PullRequestRemoteRegex.exec(config.key); - - if (matches && config.key !== `branch.${branchName}.remote` && config.value === remoteName!) { - return true; - } - - return false; - }); - } - - return { - branch: branchName!, - remote: remoteName, - createdForPullRequest, - remoteInUse, - }; - } catch (_) { - return { - branch: branchName!, - }; - } - } - - return null; - } - - private static buildPullRequestMetadata(pullRequest: PullRequestModel) { - return `${pullRequest.base.repositoryCloneUrl.owner}#${pullRequest.base.repositoryCloneUrl.repositoryName}#${pullRequest.number}`; - } - - private static buildBaseBranchMetadata(owner: string, repository: string, baseBranch: string) { - return `${owner}#${repository}#${baseBranch}`; - } - - static parsePullRequestMetadata(value: string): PullRequestMetadata | undefined { - if (value) { - const matches = /(.*)#(.*)#(.*)/g.exec(value); - if (matches && matches.length === 4) { - const [, owner, repo, prNumber] = matches; - return { - owner: owner, - repositoryName: repo, - prNumber: Number(prNumber), - }; - } - } - return undefined; - } - - private static parseBaseBranchMetadata(value: string): BaseBranchMetadata | undefined { - if (value) { - const matches = /(.*)#(.*)#(.*)/g.exec(value); - if (matches && matches.length === 4) { - const [, owner, repo, branch] = matches; - return { - owner, - repositoryName: repo, - branch, - }; - } - } - return undefined; - } - - private static getMetadataKeyForBranch(branchName: string): string { - return `branch.${branchName}.${PullRequestMetadataKey}`; - } - - private static getBaseBranchMetadataKeyForBranch(branchName: string): string { - return `branch.${branchName}.${BaseBranchMetadataKey}`; - } - - static async getMatchingPullRequestMetadataForBranch( - repository: Repository, - branchName: string, - ): Promise { - try { - const configKey = this.getMetadataKeyForBranch(branchName); - const configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parsePullRequestMetadata(configValue); - } catch (_) { - return; - } - } - - static async getMatchingBaseBranchMetadataForBranch( - repository: Repository, - branchName: string, - ): Promise { - try { - const configKey = this.getBaseBranchMetadataKeyForBranch(branchName); - const configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parseBaseBranchMetadata(configValue); - } catch (_) { - return; - } - } - - static async createRemote(repository: Repository, baseRemote: Remote, cloneUrl: Protocol) { - Logger.appendLine(`create remote for ${cloneUrl}.`, PullRequestGitHelper.ID); - - const remotes = parseRepositoryRemotes(repository); - for (const remote of remotes) { - if (new Protocol(remote.url).equals(cloneUrl)) { - return remote.remoteName; - } - } - - const remoteName = PullRequestGitHelper.getUniqueRemoteName(repository, cloneUrl.owner); - cloneUrl.update({ - type: baseRemote.gitProtocol.type, - }); - await repository.addRemote(remoteName, cloneUrl.toString()!); - await repository.setConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`, 'true'); - return remoteName; - } - - static async isRemoteCreatedForPullRequest(repository: Repository, remoteName: string) { - try { - Logger.debug( - `Check if remote '${remoteName}' is created for pull request - start`, - PullRequestGitHelper.ID, - ); - const isForPR = await repository.getConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`); - Logger.debug(`Check if remote '${remoteName}' is created for pull request - end`, PullRequestGitHelper.ID); - return isForPR === 'true'; - } catch (_) { - return false; - } - } - - static async calculateUniqueBranchNameForPR( - repository: Repository, - pullRequest: PullRequestModel, - ): Promise { - const branchName = `pr/${pullRequest.author.login}/${pullRequest.number}`; - let result = branchName; - let number = 1; - - while (true) { - try { - await repository.getBranch(result); - result = `${branchName}-${number++}`; - } catch (err) { - break; - } - } - - return result; - } - - static getUniqueRemoteName(repository: Repository, name: string) { - let uniqueName = name; - let number = 1; - const remotes = parseRepositoryRemotes(repository); - - // eslint-disable-next-line no-loop-func - while (remotes.find(e => e.remoteName === uniqueName)) { - uniqueName = `${name}${number++}`; - } - - return uniqueName; - } - - static getHeadRemoteForPullRequest( - remotes: Remote[], - pullRequest: PullRequestModel & IResolvedPullRequestModel, - ): Remote | undefined { - return remotes.find( - remote => remote.gitProtocol && (remote.gitProtocol.owner.toLowerCase() === pullRequest.head.repositoryCloneUrl.owner.toLowerCase()) && (remote.gitProtocol.repositoryName.toLowerCase() === pullRequest.head.repositoryCloneUrl.repositoryName.toLowerCase()) - ); - } - - static async associateBranchWithPullRequest( - repository: Repository, - pullRequest: PullRequestModel, - branchName: string, - ) { - try { - Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); - const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); - } catch (e) { - Logger.error(`associate ${branchName} with Pull Request #${pullRequest.number} failed`, PullRequestGitHelper.ID); - } - } - - static async associateBaseBranchWithBranch( - repository: Repository, - branch: string, - owner: string, - repo: string, - baseBranch: string - ) { - try { - Logger.appendLine(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch}`, PullRequestGitHelper.ID); - const prConfigKey = `branch.${branch}.${BaseBranchMetadataKey}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildBaseBranchMetadata(owner, repo, baseBranch)); - } catch (e) { - Logger.error(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch} failed`, PullRequestGitHelper.ID); - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +/* + * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/165a97bdcab7559e0c4393a571b9ff2aed4ba8a7/src/GitHub.App/Services/PullRequestService.cs + */ +import * as vscode from 'vscode'; +import { Branch, Repository } from '../api/api'; +import { GitErrorCodes } from '../api/api1'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { parseRepositoryRemotes, Remote } from '../common/remote'; +import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; + +const PullRequestRemoteMetadataKey = 'github-pr-remote'; +export const PullRequestMetadataKey = 'github-pr-owner-number'; +const BaseBranchMetadataKey = 'github-pr-base-branch'; +const PullRequestBranchRegex = /branch\.(.+)\.github-pr-owner-number/; +const PullRequestRemoteRegex = /branch\.(.+)\.remote/; + +export interface PullRequestMetadata { + owner: string; + repositoryName: string; + prNumber: number; +} + +export interface BaseBranchMetadata { + owner: string; + repositoryName: string; + branch: string; +} + +export class PullRequestGitHelper { + static ID = 'PullRequestGitHelper'; + static async checkoutFromFork( + repository: Repository, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + remoteName: string | undefined, + progress: vscode.Progress<{ message?: string; increment?: number }> + ) { + // the branch is from a fork + const localBranchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + + // create remote for this fork + if (!remoteName) { + Logger.appendLine( + `Branch ${localBranchName} is from a fork. Create a remote first.`, + PullRequestGitHelper.ID, + ); + progress.report({ message: vscode.l10n.t('Creating git remote for {0}', `${pullRequest.remote.owner}/${pullRequest.remote.repositoryName}`) }); + remoteName = await PullRequestGitHelper.createRemote( + repository, + pullRequest.remote, + pullRequest.head.repositoryCloneUrl, + ); + } + + // fetch the branch + const ref = `${pullRequest.head.ref}:${localBranchName}`; + Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - start`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', ref) }); + await repository.fetch(remoteName, ref, 1); + Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - done`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Checking out {0}', ref) }); + await repository.checkout(localBranchName); + // set remote tracking branch for the local branch + await repository.setBranchUpstream(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); + // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. + this.unshallow(repository); + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); + } + + static async fetchAndCheckout( + repository: Repository, + remotes: Remote[], + pullRequest: PullRequestModel, + progress: vscode.Progress<{ message?: string; increment?: number }> + ): Promise { + if (!pullRequest.validatePullRequestModel('Checkout pull request failed')) { + return; + } + + const remote = PullRequestGitHelper.getHeadRemoteForPullRequest(remotes, pullRequest); + const isFork = pullRequest.head.repositoryCloneUrl.owner !== pullRequest.base.repositoryCloneUrl.owner; + if (!remote || isFork) { + return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName, progress); + } + + const branchName = pullRequest.head.ref; + const remoteName = remote.remoteName; + let branch: Branch; + + try { + branch = await repository.getBranch(branchName); + // Make sure we aren't already on this branch + if (repository.state.HEAD?.name === branch.name) { + Logger.appendLine(`Tried to checkout ${branchName}, but branch is already checked out.`, PullRequestGitHelper.ID); + return; + } + Logger.debug(`Checkout ${branchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Checking out {0}', branchName) }); + await repository.checkout(branchName); + + if (!branch.upstream) { + // this branch is not associated with upstream yet + const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; + await repository.setBranchUpstream(branchName, trackedBranchName); + } + + if (branch.behind !== undefined && branch.behind > 0 && branch.ahead === 0) { + Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Pulling {0}', branchName) }); + await repository.pull(); + } + } catch (err) { + // there is no local branch with the same name, so we are good to fetch, create and checkout the remote branch. + Logger.appendLine( + `Branch ${remoteName}/${branchName} doesn't exist on local disk yet.`, + PullRequestGitHelper.ID, + ); + const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; + Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); + await repository.fetch(remoteName, branchName, 1); + const trackedBranch = await repository.getBranch(trackedBranchName); + // create branch + progress.report({ message: vscode.l10n.t('Creating and checking out branch {0}', branchName) }); + await repository.createBranch(branchName, true, trackedBranch.commit); + await repository.setBranchUpstream(branchName, trackedBranchName); + + // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. + this.unshallow(repository); + } + + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, branchName); + } + + /** + * Attempt to unshallow the repository. If it has been unshallowed in the interim, running with `--unshallow` + * will fail, so fall back to a normal pull. + */ + static async unshallow(repository: Repository): Promise { + let error: Error & { gitErrorCode?: GitErrorCodes }; + try { + await repository.pull(true); + return; + } catch (e) { + Logger.appendLine(`Unshallowing failed: ${e}.`); + if (e.stderr && (e.stderr as string).includes('would clobber existing tag')) { + // ignore this error + return; + } + error = e; + } + try { + if (error.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + Logger.appendLine(`Getting status and trying unshallow again.`); + await repository.status(); + await repository.pull(true); + return; + } + } catch (e) { + Logger.appendLine(`Unshallowing still failed: ${e}.`); + } + try { + Logger.appendLine(`Falling back to git pull.`); + await repository.pull(false); + } catch (e) { + Logger.error(`Pull after failed unshallow still failed: ${e}`); + throw e; + } + } + + static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>) { + const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); + const configs = await repository.getConfigs(); + + const readConfig = (searchKey: string): string | undefined => + configs.filter(({ key: k }) => searchKey === k).map(({ value }) => value)[0]; + + const branchInfos = configs + .map(config => { + const matches = PullRequestBranchRegex.exec(config.key); + return { + branch: matches && matches.length ? matches[1] : null, + value: config.value, + }; + }) + .filter(c => c.branch && c.value === key); + + if (branchInfos && branchInfos.length) { + // let's immediately checkout to branchInfos[0].branch + const branchName = branchInfos[0].branch!; + progress.report({ message: vscode.l10n.t('Checking out branch {0}', branchName) }); + await repository.checkout(branchName); + + // respect the git setting to fetch before checkout + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true)) { + const remote = readConfig(`branch.${branchName}.remote`); + const ref = readConfig(`branch.${branchName}.merge`); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); + await repository.fetch(remote, ref); + } + + const branchStatus = await repository.getBranch(branchInfos[0].branch!); + if (branchStatus.upstream === undefined) { + return false; + } + + if (branchStatus.behind !== undefined && branchStatus.behind > 0 && branchStatus.ahead === 0) { + Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Pulling branch {0}', branchName) }); + await repository.pull(); + } + + return true; + } else { + return false; + } + } + + static async getBranchNRemoteForPullRequest( + repository: Repository, + pullRequest: PullRequestModel, + ): Promise<{ + branch: string; + remote?: string; + createdForPullRequest?: boolean; + remoteInUse?: boolean; + } | null> { + const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); + const configs = await repository.getConfigs(); + + const branchInfo = configs + .map(config => { + const matches = PullRequestBranchRegex.exec(config.key); + return { + branch: matches && matches.length ? matches[1] : null, + value: config.value, + }; + }) + .find(c => !!c.branch && c.value === key); + + if (branchInfo) { + // we find the branch + const branchName = branchInfo.branch; + + try { + const configKey = `branch.${branchName}.remote`; + const branchRemotes = configs.filter(config => config.key === configKey).map(config => config.value); + let remoteName: string | undefined = undefined; + if (branchRemotes.length) { + remoteName = branchRemotes[0]; + } + + let createdForPullRequest = false; + if (remoteName) { + const remoteCreatedForPullRequestKey = `remote.${remoteName}.github-pr-remote`; + const remoteCreatedForPullRequest = configs.filter( + config => config.key === remoteCreatedForPullRequestKey && config.value, + ); + + if (remoteCreatedForPullRequest.length) { + // it's created for pull request + createdForPullRequest = true; + } + } + + let remoteInUse: boolean | undefined; + if (createdForPullRequest) { + // try to find other branches under this remote + remoteInUse = configs.some(config => { + const matches = PullRequestRemoteRegex.exec(config.key); + + if (matches && config.key !== `branch.${branchName}.remote` && config.value === remoteName!) { + return true; + } + + return false; + }); + } + + return { + branch: branchName!, + remote: remoteName, + createdForPullRequest, + remoteInUse, + }; + } catch (_) { + return { + branch: branchName!, + }; + } + } + + return null; + } + + private static buildPullRequestMetadata(pullRequest: PullRequestModel) { + return `${pullRequest.base.repositoryCloneUrl.owner}#${pullRequest.base.repositoryCloneUrl.repositoryName}#${pullRequest.number}`; + } + + private static buildBaseBranchMetadata(owner: string, repository: string, baseBranch: string) { + return `${owner}#${repository}#${baseBranch}`; + } + + static parsePullRequestMetadata(value: string): PullRequestMetadata | undefined { + if (value) { + const matches = /(.*)#(.*)#(.*)/g.exec(value); + if (matches && matches.length === 4) { + const [, owner, repo, prNumber] = matches; + return { + owner: owner, + repositoryName: repo, + prNumber: Number(prNumber), + }; + } + } + return undefined; + } + + private static parseBaseBranchMetadata(value: string): BaseBranchMetadata | undefined { + if (value) { + const matches = /(.*)#(.*)#(.*)/g.exec(value); + if (matches && matches.length === 4) { + const [, owner, repo, branch] = matches; + return { + owner, + repositoryName: repo, + branch, + }; + } + } + return undefined; + } + + private static getMetadataKeyForBranch(branchName: string): string { + return `branch.${branchName}.${PullRequestMetadataKey}`; + } + + private static getBaseBranchMetadataKeyForBranch(branchName: string): string { + return `branch.${branchName}.${BaseBranchMetadataKey}`; + } + + static async getMatchingPullRequestMetadataForBranch( + repository: Repository, + branchName: string, + ): Promise { + try { + const configKey = this.getMetadataKeyForBranch(branchName); + const configValue = await repository.getConfig(configKey); + return PullRequestGitHelper.parsePullRequestMetadata(configValue); + } catch (_) { + return; + } + } + + static async getMatchingBaseBranchMetadataForBranch( + repository: Repository, + branchName: string, + ): Promise { + try { + const configKey = this.getBaseBranchMetadataKeyForBranch(branchName); + const configValue = await repository.getConfig(configKey); + return PullRequestGitHelper.parseBaseBranchMetadata(configValue); + } catch (_) { + return; + } + } + + static async createRemote(repository: Repository, baseRemote: Remote, cloneUrl: Protocol) { + Logger.appendLine(`create remote for ${cloneUrl}.`, PullRequestGitHelper.ID); + + const remotes = parseRepositoryRemotes(repository); + for (const remote of remotes) { + if (new Protocol(remote.url).equals(cloneUrl)) { + return remote.remoteName; + } + } + + const remoteName = PullRequestGitHelper.getUniqueRemoteName(repository, cloneUrl.owner); + cloneUrl.update({ + type: baseRemote.gitProtocol.type, + }); + await repository.addRemote(remoteName, cloneUrl.toString()!); + await repository.setConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`, 'true'); + return remoteName; + } + + static async isRemoteCreatedForPullRequest(repository: Repository, remoteName: string) { + try { + Logger.debug( + `Check if remote '${remoteName}' is created for pull request - start`, + PullRequestGitHelper.ID, + ); + const isForPR = await repository.getConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`); + Logger.debug(`Check if remote '${remoteName}' is created for pull request - end`, PullRequestGitHelper.ID); + return isForPR === 'true'; + } catch (_) { + return false; + } + } + + static async calculateUniqueBranchNameForPR( + repository: Repository, + pullRequest: PullRequestModel, + ): Promise { + const branchName = `pr/${pullRequest.author.login}/${pullRequest.number}`; + let result = branchName; + let number = 1; + + while (true) { + try { + await repository.getBranch(result); + result = `${branchName}-${number++}`; + } catch (err) { + break; + } + } + + return result; + } + + static getUniqueRemoteName(repository: Repository, name: string) { + let uniqueName = name; + let number = 1; + const remotes = parseRepositoryRemotes(repository); + + // eslint-disable-next-line no-loop-func + while (remotes.find(e => e.remoteName === uniqueName)) { + uniqueName = `${name}${number++}`; + } + + return uniqueName; + } + + static getHeadRemoteForPullRequest( + remotes: Remote[], + pullRequest: PullRequestModel & IResolvedPullRequestModel, + ): Remote | undefined { + return remotes.find( + remote => remote.gitProtocol && (remote.gitProtocol.owner.toLowerCase() === pullRequest.head.repositoryCloneUrl.owner.toLowerCase()) && (remote.gitProtocol.repositoryName.toLowerCase() === pullRequest.head.repositoryCloneUrl.repositoryName.toLowerCase()) + ); + } + + static async associateBranchWithPullRequest( + repository: Repository, + pullRequest: PullRequestModel, + branchName: string, + ) { + try { + Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); + const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); + } catch (e) { + Logger.error(`associate ${branchName} with Pull Request #${pullRequest.number} failed`, PullRequestGitHelper.ID); + } + } + + static async associateBaseBranchWithBranch( + repository: Repository, + branch: string, + owner: string, + repo: string, + baseBranch: string + ) { + try { + Logger.appendLine(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch}`, PullRequestGitHelper.ID); + const prConfigKey = `branch.${branch}.${BaseBranchMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildBaseBranchMetadata(owner, repo, baseBranch)); + } catch (e) { + Logger.error(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch} failed`, PullRequestGitHelper.ID); + } + } +} diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index ddbb69ba1c..36e4c3fbee 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -1,1840 +1,1841 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as buffer from 'buffer'; -import * as path from 'path'; -import equals from 'fast-deep-equal'; -import gql from 'graphql-tag'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; -import { parseDiff } from '../common/diffHunk'; -import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; -import { GitHubRef } from '../common/githubRef'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { ReviewEvent as CommonReviewEvent, EventType, TimelineEvent } from '../common/timelineEvent'; -import { resolvePath, Schemes, toPRUri, toReviewUri } from '../common/uri'; -import { formatError } from '../common/utils'; -import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; -import { OctokitCommon } from './common'; -import { CredentialStore } from './credentials'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; -import { - AddCommentResponse, - AddReactionResponse, - AddReviewThreadResponse, - DeleteReactionResponse, - DeleteReviewResponse, - DequeuePullRequestResponse, - EditCommentResponse, - EnqueuePullRequestResponse, - GetReviewRequestsResponse, - LatestReviewCommitResponse, - MarkPullRequestReadyForReviewResponse, - PendingReviewIdResponse, - PullRequestCommentsResponse, - PullRequestFilesResponse, - PullRequestMergabilityResponse, - ReactionGroup, - ResolveReviewThreadResponse, - StartReviewResponse, - SubmitReviewResponse, - TimelineEventsResponse, - UnresolveReviewThreadResponse, - UpdatePullRequestResponse, -} from './graphql'; -import { - GithubItemStateEnum, - IAccount, - IRawFileChange, - ISuggestedReviewer, - ITeam, - MergeMethod, - MergeQueueEntry, - PullRequest, - PullRequestChecks, - PullRequestMergeability, - PullRequestReviewRequirement, - ReviewEvent, -} from './interface'; -import { IssueModel } from './issueModel'; -import { - convertRESTPullRequestToRawPullRequest, - convertRESTReviewEvent, - getAvatarWithEnterpriseFallback, - getReactionGroup, - insertNewCommitsSinceReview, - parseGraphQLComment, - parseGraphQLReaction, - parseGraphQLReviewEvent, - parseGraphQLReviewThread, - parseGraphQLTimelineEvents, - parseMergeability, - parseMergeQueueEntry, - restPaginate, -} from './utils'; - -interface IPullRequestModel { - head: GitHubRef | null; -} - -export interface IResolvedPullRequestModel extends IPullRequestModel { - head: GitHubRef; -} - -export interface ReviewThreadChangeEvent { - added: IReviewThread[]; - changed: IReviewThread[]; - removed: IReviewThread[]; -} - -export interface FileViewedStateChangeEvent { - changed: { - fileName: string; - viewed: ViewedState; - }[]; -} - -export type FileViewedState = { [key: string]: ViewedState }; - -const BATCH_SIZE = 100; - -export class PullRequestModel extends IssueModel implements IPullRequestModel { - static ID = 'PullRequestModel'; - - public isDraft?: boolean; - public localBranchName?: string; - public mergeBase?: string; - public mergeQueueEntry?: MergeQueueEntry; - public suggestedReviewers?: ISuggestedReviewer[]; - public hasChangesSinceLastReview?: boolean; - private _showChangesSinceReview: boolean; - private _hasPendingReview: boolean = false; - private _onDidChangePendingReviewState: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; - - private _reviewThreadsCache: IReviewThread[] = []; - private _reviewThreadsCacheInitialized = false; - private _onDidChangeReviewThreads = new vscode.EventEmitter(); - public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event; - - private _fileChangeViewedState: FileViewedState = {}; - private _viewedFiles: Set = new Set(); - private _unviewedFiles: Set = new Set(); - private _onDidChangeFileViewedState = new vscode.EventEmitter(); - public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event; - - private _onDidChangeChangesSinceReview = new vscode.EventEmitter(); - public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event; - - private _comments: readonly IComment[] | undefined; - private _onDidChangeComments: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidChangeComments: vscode.Event = this._onDidChangeComments.event; - - // Whether the pull request is currently checked out locally - private _isActive: boolean; - public get isActive(): boolean { - return this._isActive; - } - public set isActive(isActive: boolean) { - this._isActive = isActive; - } - - _telemetry: ITelemetry; - - constructor( - private readonly credentialStore: CredentialStore, - telemetry: ITelemetry, - githubRepository: GitHubRepository, - remote: Remote, - item: PullRequest, - isActive?: boolean, - ) { - super(githubRepository, remote, item, true); - - this._telemetry = telemetry; - this.isActive = !!isActive; - - this._showChangesSinceReview = false; - - this.update(item); - } - - public clear() { - this.comments = []; - this._reviewThreadsCacheInitialized = false; - this._reviewThreadsCache = []; - } - - public async initializeReviewThreadCache(): Promise { - await this.getReviewThreads(); - this._reviewThreadsCacheInitialized = true; - } - - public get reviewThreadsCache(): IReviewThread[] { - return this._reviewThreadsCache; - } - - public get reviewThreadsCacheReady(): boolean { - return this._reviewThreadsCacheInitialized; - } - - public get isMerged(): boolean { - return this.state === GithubItemStateEnum.Merged; - } - - public get hasPendingReview(): boolean { - return this._hasPendingReview; - } - - public set hasPendingReview(hasPendingReview: boolean) { - if (this._hasPendingReview !== hasPendingReview) { - this._hasPendingReview = hasPendingReview; - this._onDidChangePendingReviewState.fire(this._hasPendingReview); - } - } - - public get showChangesSinceReview() { - return this._showChangesSinceReview; - } - - public set showChangesSinceReview(isChangesSinceReview: boolean) { - if (this._showChangesSinceReview !== isChangesSinceReview) { - this._showChangesSinceReview = isChangesSinceReview; - this._fileChanges.clear(); - this._onDidChangeChangesSinceReview.fire(); - } - } - - get comments(): readonly IComment[] { - return this._comments ?? []; - } - - set comments(comments: readonly IComment[]) { - this._comments = comments; - this._onDidChangeComments.fire(); - } - - get fileChangeViewedState(): FileViewedState { - return this._fileChangeViewedState; - } - - public isRemoteHeadDeleted?: boolean; - public head: GitHubRef | null; - public isRemoteBaseDeleted?: boolean; - public base: GitHubRef; - - protected updateState(state: string) { - if (state.toLowerCase() === 'open') { - this.state = GithubItemStateEnum.Open; - } else if (state.toLowerCase() === 'merged' || this.item.merged) { - this.state = GithubItemStateEnum.Merged; - } else { - this.state = GithubItemStateEnum.Closed; - } - } - - update(item: PullRequest): void { - super.update(item); - this.isDraft = item.isDraft; - this.suggestedReviewers = item.suggestedReviewers; - - if (item.isRemoteHeadDeleted != null) { - this.isRemoteHeadDeleted = item.isRemoteHeadDeleted; - } - if (item.head) { - this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name, item.head.repo.isInOrganization); - } - - if (item.isRemoteBaseDeleted != null) { - this.isRemoteBaseDeleted = item.isRemoteBaseDeleted; - } - if (item.base) { - this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name, item.base.repo.isInOrganization); - } - if (item.mergeQueueEntry !== undefined) { - this.mergeQueueEntry = item.mergeQueueEntry ?? undefined; - } - } - - /** - * Validate if the pull request has a valid HEAD. - * Use only when the method can fail silently, otherwise use `validatePullRequestModel` - */ - isResolved(): this is IResolvedPullRequestModel { - return !!this.head; - } - - /** - * Validate if the pull request has a valid HEAD. Show a warning message to users when the pull request is invalid. - * @param message Human readable action execution failure message. - */ - validatePullRequestModel(message?: string): this is IResolvedPullRequestModel { - if (!!this.head) { - return true; - } - - const reason = vscode.l10n.t('There is no upstream branch for Pull Request #{0}. View it on GitHub for more details', this.number); - - if (message) { - message += `: ${reason}`; - } else { - message = reason; - } - - const openString = vscode.l10n.t('Open on GitHub'); - vscode.window.showWarningMessage(message, openString).then(action => { - if (action && action === openString) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url)); - } - }); - - return false; - } - - /** - * Approve the pull request. - * @param message Optional approval comment text. - */ - async approve(repository: Repository, message?: string): Promise { - // Check that the remote head of the PR branch matches the local head of the PR branch - let remoteHead: string | undefined; - let localHead: string | undefined; - let rejectMessage: string | undefined; - if (this.isActive) { - localHead = repository.state.HEAD?.commit; - remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; - rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please pull the latest changes from the remote branch before approving.'); - } else { - localHead = this.head?.sha; - remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; - rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please refresh the pull request before approving.'); - } - - if (!remoteHead || remoteHead !== localHead) { - return Promise.reject(rejectMessage); - } - - const action: Promise = (await this.getPendingReviewId()) - ? this.submitReview(ReviewEvent.Approve, message) - : this.createReview(ReviewEvent.Approve, message); - - return action.then(x => { - /* __GDPR__ - "pr.approve" : {} - */ - this._telemetry.sendTelemetryEvent('pr.approve'); - this._onDidChangeComments.fire(); - return x; - }); - } - - /** - * Request changes on the pull request. - * @param message Optional comment text to leave with the review. - */ - async requestChanges(message?: string): Promise { - const action: Promise = (await this.getPendingReviewId()) - ? this.submitReview(ReviewEvent.RequestChanges, message) - : this.createReview(ReviewEvent.RequestChanges, message); - - return action.then(x => { - /* __GDPR__ - "pr.requestChanges" : {} - */ - this._telemetry.sendTelemetryEvent('pr.requestChanges'); - this._onDidChangeComments.fire(); - return x; - }); - } - - /** - * Close the pull request. - */ - async close(): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - const ret = await octokit.call(octokit.api.pulls.update, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - state: 'closed', - }); - - /* __GDPR__ - "pr.close" : {} - */ - this._telemetry.sendTelemetryEvent('pr.close'); - - return convertRESTPullRequestToRawPullRequest(ret.data, this.githubRepository); - } - - /** - * Create a new review. - * @param event The type of review to create, an approval, request for changes, or comment. - * @param message The summary comment text. - */ - private async createReview(event: ReviewEvent, message?: string): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - - const { data } = await octokit.call(octokit.api.pulls.createReview, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - event: event, - body: message, - }); - - return convertRESTReviewEvent(data, this.githubRepository); - } - - /** - * Submit an existing review. - * @param event The type of review to create, an approval, request for changes, or comment. - * @param body The summary comment text. - */ - async submitReview(event?: ReviewEvent, body?: string): Promise { - let pendingReviewId = await this.getPendingReviewId(); - const { mutate, schema } = await this.githubRepository.ensure(); - - if (!pendingReviewId && (event === ReviewEvent.Comment)) { - // Create a new review so that we can comment on it. - pendingReviewId = await this.startReview(); - } - - if (pendingReviewId) { - const { data } = await mutate({ - mutation: schema.SubmitReview, - variables: { - id: pendingReviewId, - event: event || ReviewEvent.Comment, - body, - }, - }); - - this.hasPendingReview = false; - await this.updateDraftModeContext(); - const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); - - const threadWithComment = this._reviewThreadsCache.find(thread => - thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, - ); - if (threadWithComment) { - threadWithComment.comments = reviewEvent.comments; - threadWithComment.viewerCanResolve = true; - this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); - } - return reviewEvent; - } else { - throw new Error(`Submitting review failed, no pending review for current pull request: ${this.number}.`); - } - } - - async updateMilestone(id: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const finalId = id === 'null' ? null : id; - - try { - await mutate({ - mutation: schema.UpdatePullRequest, - variables: { - input: { - pullRequestId: this.item.graphNodeId, - milestoneId: finalId, - }, - }, - }); - } catch (err) { - Logger.error(err, PullRequestModel.ID); - } - } - - async addAssignees(assignees: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.addAssignees, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - assignees, - }); - } - - /** - * Query to see if there is an existing review. - */ - async getPendingReviewId(): Promise { - const { query, schema } = await this.githubRepository.ensure(); - const currentUser = await this.githubRepository.getAuthenticatedUser(); - try { - const { data } = await query({ - query: schema.GetPendingReviewId, - variables: { - pullRequestId: this.item.graphNodeId, - author: currentUser, - }, - }); - return data.node.reviews.nodes.length > 0 ? data.node.reviews.nodes[0].id : undefined; - } catch (error) { - return; - } - } - - async getViewerLatestReviewCommit(): Promise<{ sha: string } | undefined> { - Logger.debug(`Fetch viewers latest review commit`, IssueModel.ID); - const { query, remote, schema } = await this.githubRepository.ensure(); - - try { - const { data } = await query({ - query: schema.LatestReviewCommit, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - - if (data.repository === null) { - Logger.error('Unexpected null repository while getting last review commit', PullRequestModel.ID); - } - - return data.repository?.pullRequest.viewerLatestReview ? { - sha: data.repository?.pullRequest.viewerLatestReview.commit.oid, - } : undefined; - } - catch (e) { - return undefined; - } - } - - /** - * Delete an existing in progress review. - */ - async deleteReview(): Promise<{ deletedReviewId: number; deletedReviewComments: IComment[] }> { - const pendingReviewId = await this.getPendingReviewId(); - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.DeleteReview, - variables: { - input: { pullRequestReviewId: pendingReviewId }, - }, - }); - - const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview; - - this.hasPendingReview = false; - await this.updateDraftModeContext(); - - this.getReviewThreads(); - - return { - deletedReviewId: databaseId, - deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)), - }; - } - - /** - * Start a new review. - * @param initialComment The comment text and position information to begin the review with - * @param commitId The optional commit id to start the review on. Defaults to using the current head commit. - */ - async startReview(commitId?: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.StartReview, - variables: { - input: { - body: '', - pullRequestId: this.item.graphNodeId, - commitOID: commitId || this.head?.sha, - }, - }, - }); - - if (!data) { - throw new Error('Failed to start review'); - } - this.hasPendingReview = true; - this._onDidChangeComments.fire(); - return data.addPullRequestReview.pullRequestReview.id; - } - - /** - * Creates a new review thread, either adding it to an existing pending review, or creating - * a new review. - * @param body The body of the thread's first comment. - * @param commentPath The path to the file being commented on. - * @param startLine The start line on which to add the comment. - * @param endLine The end line on which to add the comment. - * @param side The side the comment should be deleted on, i.e. the original or modified file. - * @param suppressDraftModeUpdate If a draft mode change should event should be suppressed. In the - * case of a single comment add, the review is created and then immediately submitted, so this prevents - * a "Pending" label from flashing on the comment. - * @returns The new review thread object. - */ - async createReviewThread( - body: string, - commentPath: string, - startLine: number | undefined, - endLine: number | undefined, - side: DiffSide, - suppressDraftModeUpdate?: boolean, - ): Promise { - if (!this.validatePullRequestModel('Creating comment failed')) { - return; - } - const pendingReviewId = await this.getPendingReviewId(); - - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.AddReviewThread, - variables: { - input: { - path: commentPath, - body, - pullRequestId: this.graphNodeId, - pullRequestReviewId: pendingReviewId, - startLine: startLine === endLine ? undefined : startLine, - line: (endLine === undefined) ? 0 : endLine, - side, - subjectType: (startLine === undefined || endLine === undefined) ? SubjectType.FILE : SubjectType.LINE - } - } - }, { mutation: schema.LegacyAddReviewThread, deleteProps: ['subjectType'] }); - - if (!data) { - throw new Error('Creating review thread failed.'); - } - - if (!data.addPullRequestReviewThread.thread) { - throw new Error('File has been deleted.'); - } - - if (!suppressDraftModeUpdate) { - this.hasPendingReview = true; - await this.updateDraftModeContext(); - } - - const thread = data.addPullRequestReviewThread.thread; - const newThread = parseGraphQLReviewThread(thread, this.githubRepository); - this._reviewThreadsCache.push(newThread); - this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); - return newThread; - } - - /** - * Creates a new comment in reply to an existing comment - * @param body The text of the comment to be created - * @param inReplyTo The id of the comment this is in reply to - * @param isSingleComment Whether this is a single comment, i.e. one that - * will be immediately submitted and so should not show a pending label - * @param commitId The commit id the comment was made on - * @returns The new comment - */ - async createCommentReply( - body: string, - inReplyTo: string, - isSingleComment: boolean, - commitId?: string, - ): Promise { - if (!this.validatePullRequestModel('Creating comment failed')) { - return; - } - - let pendingReviewId = await this.getPendingReviewId(); - if (!pendingReviewId) { - pendingReviewId = await this.startReview(commitId); - } - - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.AddComment, - variables: { - input: { - pullRequestReviewId: pendingReviewId, - body, - inReplyTo, - commitOID: commitId || this.head?.sha, - }, - }, - }); - - if (!data) { - throw new Error('Creating comment reply failed.'); - } - - const { comment } = data.addPullRequestReviewComment; - const newComment = parseGraphQLComment(comment, false, this.githubRepository); - - if (isSingleComment) { - newComment.isDraft = false; - } - - const threadWithComment = this._reviewThreadsCache.find(thread => - thread.comments.some(comment => comment.graphNodeId === inReplyTo), - ); - if (threadWithComment) { - threadWithComment.comments.push(newComment); - this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); - } - - return newComment; - } - - /** - * Check whether there is an existing pending review and update the context key to control what comment actions are shown. - */ - async validateDraftMode(): Promise { - const inDraftMode = !!(await this.getPendingReviewId()); - if (inDraftMode !== this.hasPendingReview) { - this.hasPendingReview = inDraftMode; - } - - await this.updateDraftModeContext(); - - return inDraftMode; - } - - private async updateDraftModeContext() { - if (this.isActive) { - await vscode.commands.executeCommand('setContext', 'reviewInDraftMode', this.hasPendingReview); - } - } - - /** - * Edit an existing review comment. - * @param comment The comment to edit - * @param text The new comment text - */ - async editReviewComment(comment: IComment, text: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - let threadWithComment = this._reviewThreadsCache.find(thread => - thread.comments.some(c => c.graphNodeId === comment.graphNodeId), - ); - - if (!threadWithComment) { - return this.editIssueComment(comment, text); - } - - const { data } = await mutate({ - mutation: schema.EditComment, - variables: { - input: { - pullRequestReviewCommentId: comment.graphNodeId, - body: text, - }, - }, - }); - - if (!data) { - throw new Error('Editing review comment failed.'); - } - - const newComment = parseGraphQLComment( - data.updatePullRequestReviewComment.pullRequestReviewComment, - !!comment.isResolved, - this.githubRepository - ); - if (threadWithComment) { - const index = threadWithComment.comments.findIndex(c => c.graphNodeId === comment.graphNodeId); - threadWithComment.comments.splice(index, 1, newComment); - this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); - } - - return newComment; - } - - /** - * Deletes a review comment. - * @param commentId The comment id to delete - */ - async deleteReviewComment(commentId: string): Promise { - try { - const { octokit, remote } = await this.githubRepository.ensure(); - const id = Number(commentId); - const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id)); - - if (threadIndex === -1) { - this.deleteIssueComment(commentId); - } else { - await octokit.call(octokit.api.pulls.deleteReviewComment, { - owner: remote.owner, - repo: remote.repositoryName, - comment_id: id, - }); - - if (threadIndex > -1) { - const threadWithComment = this._reviewThreadsCache[threadIndex]; - const index = threadWithComment.comments.findIndex(c => c.id === id); - threadWithComment.comments.splice(index, 1); - if (threadWithComment.comments.length === 0) { - this._reviewThreadsCache.splice(threadIndex, 1); - this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); - } else { - this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); - } - } - } - } catch (e) { - throw new Error(formatError(e)); - } - } - - /** - * Get existing requests to review. - */ - async getReviewRequests(): Promise<(IAccount | ITeam)[]> { - const githubRepository = this.githubRepository; - const { remote, query, schema } = await githubRepository.ensure(); - - const { data } = await query({ - query: this.credentialStore.isAuthenticatedWithAdditionalScopes(githubRepository.remote.authProviderId) ? schema.GetReviewRequestsAdditionalScopes : schema.GetReviewRequests, - variables: { - number: this.number, - owner: remote.owner, - name: remote.repositoryName - }, - }); - - if (data.repository === null) { - Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); - return []; - } - - const reviewers: (IAccount | ITeam)[] = []; - for (const reviewer of data.repository.pullRequest.reviewRequests.nodes) { - if (reviewer.requestedReviewer?.login) { - const account: IAccount = { - login: reviewer.requestedReviewer.login, - url: reviewer.requestedReviewer.url, - avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), - email: reviewer.requestedReviewer.email, - name: reviewer.requestedReviewer.name, - id: reviewer.requestedReviewer.id - }; - reviewers.push(account); - } else if (reviewer.requestedReviewer) { - const team: ITeam = { - name: reviewer.requestedReviewer.name, - url: reviewer.requestedReviewer.url, - avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), - id: reviewer.requestedReviewer.id!, - org: remote.owner, - slug: reviewer.requestedReviewer.slug! - }; - reviewers.push(team); - } - } - return reviewers; - } - - /** - * Add reviewers to a pull request - * @param reviewers A list of GitHub logins - */ - async requestReview(reviewers: string[], teamReviewers: string[]): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - await mutate({ - mutation: schema.AddReviewers, - variables: { - input: { - pullRequestId: this.graphNodeId, - teamIds: teamReviewers, - userIds: reviewers - }, - }, - }); - } - - /** - * Remove a review request that has not yet been completed - * @param reviewer A GitHub Login - */ - async deleteReviewRequest(reviewers: string[], teamReviewers: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.pulls.removeRequestedReviewers, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - reviewers, - team_reviewers: teamReviewers - }); - } - - async deleteAssignees(assignees: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.removeAssignees, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - assignees, - }); - } - - private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void { - const added: IReviewThread[] = []; - const changed: IReviewThread[] = []; - const removed: IReviewThread[] = []; - - newReviewThreads.forEach(thread => { - const existingThread = oldReviewThreads.find(t => t.id === thread.id); - if (existingThread) { - if (!equals(thread, existingThread)) { - changed.push(thread); - } - } else { - added.push(thread); - } - }); - - oldReviewThreads.forEach(thread => { - if (!newReviewThreads.find(t => t.id === thread.id)) { - removed.push(thread); - } - }); - - this._onDidChangeReviewThreads.fire({ - added, - changed, - removed, - }); - } - - async getReviewThreads(): Promise { - const { remote, query, schema } = await this.githubRepository.ensure(); - let after: string | null = null; - let hasNextPage = false; - const reviewThreads: IReviewThread[] = []; - try { - do { - const { data } = await query({ - query: schema.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - after - }, - }, false, { query: schema.LegacyPullRequestComments }); - - reviewThreads.push(...data.repository.pullRequest.reviewThreads.nodes.map(node => { - return parseGraphQLReviewThread(node, this.githubRepository); - })); - - hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; - after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; - } while (hasNextPage && reviewThreads.length < 1000); - - const oldReviewThreads = this._reviewThreadsCache; - this._reviewThreadsCache = reviewThreads; - this.diffThreads(oldReviewThreads, reviewThreads); - return reviewThreads; - } catch (e) { - Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); - return []; - } - } - - /** - * Get all review comments. - */ - async initializeReviewComments(): Promise { - const { remote, query, schema } = await this.githubRepository.ensure(); - let after: string | null = null; - let hasNextPage = false; - const comments: IComment[] = []; - try { - do { - const { data } = await query({ - query: schema.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - after, - }, - }, false, { query: schema.LegacyPullRequestComments }); - - comments.push(...data.repository.pullRequest.reviewThreads.nodes - .map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote)) - .reduce((prev, curr) => prev.concat(curr), []) - .sort((a: IComment, b: IComment) => { - return a.createdAt > b.createdAt ? 1 : -1; - })); - - hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; - after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; - } while (hasNextPage && comments.length < 1000); - this.comments = comments; - } catch (e) { - Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); - } - } - - /** - * Get a list of the commits within a pull request. - */ - async getCommits(): Promise { - try { - Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID); - const { remote, octokit } = await this.githubRepository.ensure(); - const commitData = await restPaginate(octokit.api.pulls.listCommits, { - pull_number: this.number, - owner: remote.owner, - repo: remote.repositoryName, - }); - Logger.debug(`Fetch commits of PR #${this.number} - done`, PullRequestModel.ID); - - return commitData; - } catch (e) { - vscode.window.showErrorMessage(`Fetching commits failed: ${formatError(e)}`); - return []; - } - } - - /** - * Get all changed files within a commit - * @param commit The commit - */ - async getCommitChangedFiles( - commit: OctokitCommon.PullsListCommitsResponseData[0], - ): Promise { - try { - Logger.debug( - `Fetch file changes of commit ${commit.sha} in PR #${this.number} - enter`, - PullRequestModel.ID, - ); - const { octokit, remote } = await this.githubRepository.ensure(); - const fullCommit = await octokit.call(octokit.api.repos.getCommit, { - owner: remote.owner, - repo: remote.repositoryName, - ref: commit.sha, - }); - Logger.debug( - `Fetch file changes of commit ${commit.sha} in PR #${this.number} - done`, - PullRequestModel.ID, - ); - - return fullCommit.data.files ?? []; - } catch (e) { - vscode.window.showErrorMessage(`Fetching commit file changes failed: ${formatError(e)}`); - return []; - } - } - - /** - * Gets file content for a file at the specified commit - * @param filePath The file path - * @param commit The commit - */ - async getFile(filePath: string, commit: string) { - const { octokit, remote } = await this.githubRepository.ensure(); - const fileContent = await octokit.call(octokit.api.repos.getContent, { - owner: remote.owner, - repo: remote.repositoryName, - path: filePath, - ref: commit, - }); - - if (Array.isArray(fileContent.data)) { - throw new Error(`Unexpected array response when getting file ${filePath}`); - } - - const contents = (fileContent.data as any).content ?? ''; - const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); - return buff.toString(); - } - - /** - * Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns. - */ - async getTimelineEvents(): Promise { - Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID); - const { query, remote, schema } = await this.githubRepository.ensure(); - - try { - const [{ data }, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([ - query({ - query: schema.TimelineEvents, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }), - this.getViewerLatestReviewCommit(), - this.githubRepository.getAuthenticatedUser(), - this.getReviewThreads() - ]); - - if (data.repository === null) { - Logger.error('Unexpected null repository when fetching timeline', PullRequestModel.ID); - } - - const ret = data.repository?.pullRequest.timelineItems.nodes; - const events = ret ? parseGraphQLTimelineEvents(ret, this.githubRepository) : []; - - this.addReviewTimelineEventComments(events, reviewThreads); - insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); - - return events; - } catch (e) { - console.log(e); - return []; - } - } - - private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void { - interface CommentNode extends IComment { - childComments?: CommentNode[]; - } - - const reviewEvents = events.filter((e): e is CommonReviewEvent => e.event === EventType.Reviewed); - const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []); - - const reviewEventsById = reviewEvents.reduce((index, evt) => { - index[evt.id] = evt; - evt.comments = []; - return index; - }, {} as { [key: number]: CommonReviewEvent }); - - const commentsById = reviewComments.reduce((index, evt) => { - index[evt.id] = evt; - return index; - }, {} as { [key: number]: CommentNode }); - - const roots: CommentNode[] = []; - let i = reviewComments.length; - while (i-- > 0) { - const c: CommentNode = reviewComments[i]; - if (!c.inReplyToId) { - roots.unshift(c); - continue; - } - const parent = commentsById[c.inReplyToId]; - parent.childComments = parent.childComments || []; - parent.childComments = [c, ...(c.childComments || []), ...parent.childComments]; - } - - roots.forEach(c => { - const review = reviewEventsById[c.pullRequestReviewId!]; - if (review) { - review.comments = review.comments.concat(c).concat(c.childComments || []); - } - }); - - reviewThreads.forEach(thread => { - if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) { - return; - } - const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; - prReviewThreadEvent.reviewThread = { - threadId: thread.id, - canResolve: thread.viewerCanResolve, - canUnresolve: thread.viewerCanUnresolve, - isResolved: thread.isResolved - }; - - }); - - const pendingReview = reviewEvents.filter(r => r.state.toLowerCase() === 'pending')[0]; - if (pendingReview) { - // Ensures that pending comments made in reply to other reviews are included for the pending review - pendingReview.comments = reviewComments.filter(c => c.isDraft); - } - } - - /** - * Get the status checks of the pull request, those for the last commit. - */ - async getStatusChecks(): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { - return this.githubRepository.getStatusChecks(this.number); - } - - static async openChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel) { - const isCurrentPR = folderManager.activePullRequest?.number === pullRequestModel.number; - const changes = pullRequestModel.fileChanges.size > 0 ? pullRequestModel.fileChanges.values() : await pullRequestModel.getFileChangesInfo(); - const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; - - for (const change of changes) { - let changeModel; - if (change instanceof SlimFileChange) { - changeModel = new RemoteFileChangeModel(folderManager, change, pullRequestModel); - } else { - changeModel = new InMemFileChangeModel(folderManager, pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), change, isCurrentPR, pullRequestModel.mergeBase!); - } - args.push([changeModel.filePath, changeModel.parentFilePath, changeModel.filePath]); - } - - /* __GDPR__ - "pr.openChanges" : {} - */ - folderManager.telemetry.sendTelemetryEvent('pr.openChanges'); - return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); - } - - static async openDiffFromComment( - folderManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - comment: IComment, - ): Promise { - const contentChanges = await pullRequestModel.getFileChangesInfo(); - const change = contentChanges.find( - fileChange => fileChange.fileName === comment.path || fileChange.previousFileName === comment.path, - ); - if (!change) { - throw new Error(`Can't find matching file`); - } - - const pathSegments = comment.path!.split('/'); - const line = (comment.diffHunks && comment.diffHunks.length > 0) ? comment.diffHunks[0].newLineNumber : undefined; - this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1], line); - } - - static async openFirstDiff( - folderManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - ) { - const contentChanges = await pullRequestModel.getFileChangesInfo(); - if (!contentChanges.length) { - return; - } - - const firstChange = contentChanges[0]; - this.openDiff(folderManager, pullRequestModel, firstChange, firstChange.fileName); - } - - static async openDiff( - folderManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - change: SlimFileChange | InMemFileChange, - diffTitle: string, - line?: number - ): Promise { - let headUri, baseUri: vscode.Uri; - if (!pullRequestModel.equals(folderManager.activePullRequest)) { - const headCommit = pullRequestModel.head!.sha; - const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; - headUri = toPRUri( - vscode.Uri.file(resolvePath(folderManager.repository.rootUri, change.fileName)), - pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - false, - change.status, - change.previousFileName - ); - baseUri = toPRUri( - vscode.Uri.file(resolvePath(folderManager.repository.rootUri, parentFileName)), - pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - true, - change.status, - change.previousFileName - ); - } else { - const uri = vscode.Uri.file(path.resolve(folderManager.repository.rootUri.fsPath, change.fileName)); - - headUri = - change.status === GitChangeType.DELETE - ? toReviewUri( - uri, - undefined, - undefined, - '', - false, - { base: false }, - folderManager.repository.rootUri, - ) - : uri; - - const mergeBase = pullRequestModel.mergeBase || pullRequestModel.base.sha; - baseUri = toReviewUri( - uri, - change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, - undefined, - change.status === GitChangeType.ADD ? '' : mergeBase, - false, - { base: true }, - folderManager.repository.rootUri, - ); - } - - vscode.commands.executeCommand( - 'vscode.diff', - baseUri, - headUri, - `${diffTitle} (Pull Request)`, - line ? { selection: { start: { line, character: 0 }, end: { line, character: 0 } } } : {}, - ); - } - - private _fileChanges: Map = new Map(); - get fileChanges(): Map { - return this._fileChanges; - } - - async getFileChangesInfo() { - this._fileChanges.clear(); - const data = await this.getRawFileChangesInfo(); - const mergebase = this.mergeBase || this.base.sha; - const parsed = await parseDiff(data, mergebase); - parsed.forEach(fileChange => { - this._fileChanges.set(fileChange.fileName, fileChange); - }); - return parsed; - } - - /** - * List the changed files in a pull request. - */ - private async getRawFileChangesInfo(): Promise { - Logger.debug( - `Fetch file changes, base, head and merge base of PR #${this.number} - enter`, - PullRequestModel.ID, - ); - const githubRepository = this.githubRepository; - const { octokit, remote } = await githubRepository.ensure(); - - if (!this.base) { - const info = await octokit.call(octokit.api.pulls.get, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - }); - this.update(convertRESTPullRequestToRawPullRequest(info.data, githubRepository)); - } - - let compareWithBaseRef = this.base.sha; - const latestReview = await this.getViewerLatestReviewCommit(); - const oldHasChangesSinceReview = this.hasChangesSinceLastReview; - this.hasChangesSinceLastReview = latestReview !== undefined && this.head?.sha !== latestReview.sha; - - if (this._showChangesSinceReview && this.hasChangesSinceLastReview && latestReview != undefined) { - compareWithBaseRef = latestReview.sha; - } - - if (this.item.merged) { - const response = await restPaginate(octokit.api.pulls.listFiles, { - repo: remote.repositoryName, - owner: remote.owner, - pull_number: this.number, - }); - - // Use the original base to compare against for merged PRs - this.mergeBase = this.base.sha; - - return response; - } - - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef}`, - head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`, - }); - - this.mergeBase = data.merge_base_commit.sha; - - const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100; - let files: IRawFileChange[] = []; - - if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { - // compareCommits will return a maximum of 100 changed files - // If we have (maybe) more than that, we'll need to fetch them with listFiles API call - Logger.debug( - `More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed, fetching all file changes of PR #${this.number}`, - PullRequestModel.ID, - ); - files = await restPaginate(octokit.api.pulls.listFiles, { - owner: this.base.repositoryCloneUrl.owner, - pull_number: this.number, - repo: remote.repositoryName, - }); - } else { - // if we're under the limit, just use the result from compareCommits, don't make additional API calls. - files = data.files ? data.files as IRawFileChange[] : []; - } - - if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) { - this._onDidChangeChangesSinceReview.fire(); - } - - Logger.debug( - `Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, - PullRequestModel.ID, - ); - return files; - } - - get autoMerge(): boolean { - return !!this.item.autoMerge; - } - - get autoMergeMethod(): MergeMethod | undefined { - return this.item.autoMergeMethod; - } - - get allowAutoMerge(): boolean { - return !!this.item.allowAutoMerge; - } - - get mergeCommitMeta(): { title: string; description: string } | undefined { - return this.item.mergeCommitMeta; - } - - get squashCommitMeta(): { title: string; description: string } | undefined { - return this.item.squashCommitMeta; - } - - /** - * Get the current mergeability of the pull request. - */ - async getMergeability(): Promise { - try { - Logger.debug(`Fetch pull request mergeability ${this.number} - enter`, PullRequestModel.ID); - const { query, remote, schema } = await this.githubRepository.ensure(); - - const { data } = await query({ - query: schema.PullRequestMergeability, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - if (data.repository === null) { - Logger.error('Unexpected null repository while getting mergeability', PullRequestModel.ID); - } - - Logger.debug(`Fetch pull request mergeability ${this.number} - done`, PullRequestModel.ID); - const mergeability = parseMergeability(data.repository?.pullRequest.mergeable, data.repository?.pullRequest.mergeStateStatus); - this.item.mergeable = mergeability; - return mergeability; - } catch (e) { - Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID); - return PullRequestMergeability.Unknown; - } - } - - /** - * Set a draft pull request as ready to be reviewed. - */ - async setReadyForReview(): Promise { - try { - const { mutate, schema } = await this.githubRepository.ensure(); - - const { data } = await mutate({ - mutation: schema.ReadyForReview, - variables: { - input: { - pullRequestId: this.graphNodeId, - }, - }, - }); - - /* __GDPR__ - "pr.readyForReview.success" : {} - */ - this._telemetry.sendTelemetryEvent('pr.readyForReview.success'); - - return data!.markPullRequestReadyForReview.pullRequest.isDraft; - } catch (e) { - /* __GDPR__ - "pr.readyForReview.failure" : {} - */ - this._telemetry.sendTelemetryErrorEvent('pr.readyForReview.failure'); - throw e; - } - } - - private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) { - const reviewThread = this._reviewThreadsCache.find(thread => - thread.comments.some(c => c.graphNodeId === graphNodeId), - ); - if (reviewThread) { - const updatedComment = reviewThread.comments.find(c => c.graphNodeId === graphNodeId); - if (updatedComment) { - updatedComment.reactions = parseGraphQLReaction(reactionGroups); - this._onDidChangeReviewThreads.fire({ added: [], changed: [reviewThread], removed: [] }); - } - } - } - - async addCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise { - const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { - prev[curr.label] = curr.title; - return prev; - }, {} as { [key: string]: string }); - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.AddReaction, - variables: { - input: { - subjectId: graphNodeId, - content: reactionEmojiToContent[reaction.label!], - }, - }, - }); - - if (!data) { - throw new Error('Add comment reaction failed.'); - } - - const reactionGroups = data.addReaction.subject.reactionGroups; - this.updateCommentReactions(graphNodeId, reactionGroups); - - return data; - } - - async deleteCommentReaction( - graphNodeId: string, - reaction: vscode.CommentReaction, - ): Promise { - const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { - prev[curr.label] = curr.title; - return prev; - }, {} as { [key: string]: string }); - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.DeleteReaction, - variables: { - input: { - subjectId: graphNodeId, - content: reactionEmojiToContent[reaction.label!], - }, - }, - }); - - if (!data) { - throw new Error('Delete comment reaction failed.'); - } - - const reactionGroups = data.removeReaction.subject.reactionGroups; - this.updateCommentReactions(graphNodeId, reactionGroups); - - return data; - } - - private undoOptimisticResolveState(oldThread: IReviewThread | undefined) { - if (oldThread) { - oldThread.isResolved = !oldThread.isResolved; - oldThread.viewerCanResolve = !oldThread.viewerCanResolve; - oldThread.viewerCanUnresolve = !oldThread.viewerCanUnresolve; - this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); - } - } - - async resolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); - - try { - Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); - - const { mutate, schema } = await this.githubRepository.ensure(); - - // optimistically update - if (oldThread && oldThread.viewerCanResolve) { - oldThread.isResolved = true; - oldThread.viewerCanResolve = false; - oldThread.viewerCanUnresolve = true; - this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); - } - - const { data } = await mutate({ - mutation: schema.ResolveReviewThread, - variables: { - input: { - threadId, - }, - }, - }, { mutation: schema.LegacyResolveReviewThread, deleteProps: [] }); - - if (!data) { - this.undoOptimisticResolveState(oldThread); - throw new Error('Resolve review thread failed.'); - } - - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); - } - Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); - } catch (e) { - Logger.error(`Resolve review thread failed: ${e}`, PullRequestModel.ID); - this.undoOptimisticResolveState(oldThread); - } - } - - async unresolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); - - try { - Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); - - const { mutate, schema } = await this.githubRepository.ensure(); - - // optimistically update - if (oldThread && oldThread.viewerCanUnresolve) { - oldThread.isResolved = false; - oldThread.viewerCanUnresolve = false; - oldThread.viewerCanResolve = true; - this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); - } - - const { data } = await mutate({ - mutation: schema.UnresolveReviewThread, - variables: { - input: { - threadId, - }, - }, - }, { mutation: schema.LegacyUnresolveReviewThread, deleteProps: [] }); - - if (!data) { - this.undoOptimisticResolveState(oldThread); - throw new Error('Unresolve review thread failed.'); - } - - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); - } - Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); - } catch (e) { - Logger.error(`Unresolve review thread failed: ${e}`, PullRequestModel.ID); - this.undoOptimisticResolveState(oldThread); - } - } - - async enableAutoMerge(mergeMethod: MergeMethod): Promise { - try { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.EnablePullRequestAutoMerge, - variables: { - input: { - mergeMethod: mergeMethod.toUpperCase(), - pullRequestId: this.graphNodeId - } - } - }); - - if (!data) { - throw new Error('Enable auto-merge failed.'); - } - this.item.autoMerge = true; - this.item.autoMergeMethod = mergeMethod; - } catch (e) { - if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); - } else { - throw e; - } - } - } - - async disableAutoMerge(): Promise { - try { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.DisablePullRequestAutoMerge, - variables: { - input: { - pullRequestId: this.graphNodeId - } - } - }); - - if (!data) { - throw new Error('Disable auto-merge failed.'); - } - this.item.autoMerge = false; - } catch (e) { - if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); - } else { - throw e; - } - } - } - - async dequeuePullRequest(): Promise { - Logger.debug(`Dequeue pull request ${this.number} - enter`, GitHubRepository.ID); - const { mutate, schema } = await this.githubRepository.ensure(); - if (!schema.DequeuePullRequest) { - return false; - } - try { - await mutate({ - mutation: schema.DequeuePullRequest, - variables: { - input: { - id: this.graphNodeId - } - } - }); - - Logger.debug(`Dequeue pull request ${this.number} - done`, GitHubRepository.ID); - this.mergeQueueEntry = undefined; - return true; - } catch (e) { - Logger.error(`Dequeueing pull request failed: ${e}`, GitHubRepository.ID); - return false; - } - } - - async enqueuePullRequest(): Promise { - Logger.debug(`Enqueue pull request ${this.number} - enter`, GitHubRepository.ID); - const { mutate, schema } = await this.githubRepository.ensure(); - if (!schema.EnqueuePullRequest) { - return; - } - try { - const { data } = await mutate({ - mutation: schema.EnqueuePullRequest, - variables: { - input: { - pullRequestId: this.graphNodeId - } - } - }); - - Logger.debug(`Enqueue pull request ${this.number} - done`, GitHubRepository.ID); - const temp = parseMergeQueueEntry(data?.enqueuePullRequest.mergeQueueEntry) ?? undefined; - return temp; - } catch (e) { - Logger.error(`Enqueuing pull request failed: ${e}`, GitHubRepository.ID); - } - } - - async initializePullRequestFileViewState(): Promise { - const { query, schema, remote } = await this.githubRepository.ensure(); - - const changed: { fileName: string, viewed: ViewedState }[] = []; - let after: string | null = null; - let hasNextPage = false; - - do { - const { data } = await query({ - query: schema.PullRequestFiles, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - after: after, - }, - }); - - data.repository.pullRequest.files.nodes.forEach(n => { - if (this._fileChangeViewedState[n.path] !== n.viewerViewedState) { - changed.push({ fileName: n.path, viewed: n.viewerViewedState }); - } - // No event for setting the file viewed state here. - // Instead, wait until all the changes have been made and set the context at the end. - this.setFileViewedState(n.path, n.viewerViewedState, false); - }); - - hasNextPage = data.repository.pullRequest.files.pageInfo.hasNextPage; - after = data.repository.pullRequest.files.pageInfo.endCursor; - } while (hasNextPage); - - if (changed.length) { - this._onDidChangeFileViewedState.fire({ changed }); - } - } - - async markFiles(filePathOrSubpaths: string[], event: boolean, state: 'viewed' | 'unviewed'): Promise { - const { mutate } = await this.githubRepository.ensure(); - const pullRequestId = this.graphNodeId; - - const allFilenames = filePathOrSubpaths - .map((f) => - f.startsWith(this.githubRepository.rootUri.path) - ? f.substring(this.githubRepository.rootUri.path.length + 1) - : f - ); - - const mutationName = state === 'viewed' - ? 'markFileAsViewed' - : 'unmarkFileAsViewed'; - - // We only ever send 100 mutations at once. Any more than this and - // we risk a timeout from GitHub. - for (let i = 0; i < allFilenames.length; i += BATCH_SIZE) { - const batch = allFilenames.slice(i, i + BATCH_SIZE); - // See below for an example of what a mutation produced by this - // will look like - const mutation = gql`mutation Batch${mutationName}{ - ${batch.map((filename, i) => - `alias${i}: ${mutationName}( - input: {path: "${filename}", pullRequestId: "${pullRequestId}"} - ) { clientMutationId } - ` - )} - }`; - await mutate({ mutation }); - } - - // mutation BatchUnmarkFileAsViewedInline { - // alias0: unmarkFileAsViewed( - // input: { path: "some_folder/subfolder/A.txt", pullRequestId: "PR_someid" } - // ) { - // clientMutationId - // } - // alias1: unmarkFileAsViewed( - // input: { path: "some_folder/subfolder/B.txt", pullRequestId: "PR_someid" } - // ) { - // clientMutationId - // } - // } - - filePathOrSubpaths.forEach(path => this.setFileViewedState(path, state === 'viewed' ? ViewedState.VIEWED : ViewedState.UNVIEWED, event)); - } - - async unmarkAllFilesAsViewed(): Promise { - return this.markFiles(Array.from(this.fileChanges.keys()), true, 'unviewed'); - } - - private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) { - const uri = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath); - const filePath = (this.githubRepository.rootUri.scheme === Schemes.VscodeVfs) ? uri.path : uri.fsPath; - switch (viewedState) { - case ViewedState.DISMISSED: { - this._viewedFiles.delete(filePath); - this._unviewedFiles.delete(filePath); - break; - } - case ViewedState.UNVIEWED: { - this._viewedFiles.delete(filePath); - this._unviewedFiles.add(filePath); - break; - } - case ViewedState.VIEWED: { - this._viewedFiles.add(filePath); - this._unviewedFiles.delete(filePath); - } - } - this._fileChangeViewedState[fileSubpath] = viewedState; - if (event) { - this._onDidChangeFileViewedState.fire({ changed: [{ fileName: fileSubpath, viewed: viewedState }] }); - } - } - - public getViewedFileStates() { - return { - viewed: this._viewedFiles, - unviewed: this._unviewedFiles - }; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as buffer from 'buffer'; +import * as path from 'path'; +import equals from 'fast-deep-equal'; +import gql from 'graphql-tag'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; +import { parseDiff } from '../common/diffHunk'; +import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { ITelemetry } from '../common/telemetry'; +import { ReviewEvent as CommonReviewEvent, EventType, TimelineEvent } from '../common/timelineEvent'; +import { resolvePath, Schemes, toPRUri, toReviewUri } from '../common/uri'; +import { formatError } from '../common/utils'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; +import { OctokitCommon } from './common'; +import { CredentialStore } from './credentials'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { + AddCommentResponse, + AddReactionResponse, + AddReviewThreadResponse, + DeleteReactionResponse, + DeleteReviewResponse, + DequeuePullRequestResponse, + EditCommentResponse, + EnqueuePullRequestResponse, + GetReviewRequestsResponse, + LatestReviewCommitResponse, + MarkPullRequestReadyForReviewResponse, + PendingReviewIdResponse, + PullRequestCommentsResponse, + PullRequestFilesResponse, + PullRequestMergabilityResponse, + ReactionGroup, + ResolveReviewThreadResponse, + StartReviewResponse, + SubmitReviewResponse, + TimelineEventsResponse, + UnresolveReviewThreadResponse, + UpdatePullRequestResponse, +} from './graphql'; +import { + GithubItemStateEnum, + IAccount, + IRawFileChange, + ISuggestedReviewer, + ITeam, + MergeMethod, + MergeQueueEntry, + PullRequest, + PullRequestChecks, + PullRequestMergeability, + PullRequestReviewRequirement, + ReviewEvent, +} from './interface'; +import { IssueModel } from './issueModel'; +import { + convertRESTPullRequestToRawPullRequest, + convertRESTReviewEvent, + getAvatarWithEnterpriseFallback, + getReactionGroup, + insertNewCommitsSinceReview, + parseGraphQLComment, + parseGraphQLReaction, + parseGraphQLReviewEvent, + parseGraphQLReviewThread, + parseGraphQLTimelineEvents, + parseMergeability, + parseMergeQueueEntry, + restPaginate, +} from './utils'; + +interface IPullRequestModel { + head: GitHubRef | null; +} + +export interface IResolvedPullRequestModel extends IPullRequestModel { + head: GitHubRef; +} + +export interface ReviewThreadChangeEvent { + added: IReviewThread[]; + changed: IReviewThread[]; + removed: IReviewThread[]; +} + +export interface FileViewedStateChangeEvent { + changed: { + fileName: string; + viewed: ViewedState; + }[]; +} + +export type FileViewedState = { [key: string]: ViewedState }; + +const BATCH_SIZE = 100; + +export class PullRequestModel extends IssueModel implements IPullRequestModel { + static ID = 'PullRequestModel'; + + public isDraft?: boolean; + public localBranchName?: string; + public mergeBase?: string; + public mergeQueueEntry?: MergeQueueEntry; + public suggestedReviewers?: ISuggestedReviewer[]; + public hasChangesSinceLastReview?: boolean; + private _showChangesSinceReview: boolean; + private _hasPendingReview: boolean = false; + private _onDidChangePendingReviewState: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; + + private _reviewThreadsCache: IReviewThread[] = []; + private _reviewThreadsCacheInitialized = false; + private _onDidChangeReviewThreads = new vscode.EventEmitter(); + public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event; + + private _fileChangeViewedState: FileViewedState = {}; + private _viewedFiles: Set = new Set(); + private _unviewedFiles: Set = new Set(); + private _onDidChangeFileViewedState = new vscode.EventEmitter(); + public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event; + + private _onDidChangeChangesSinceReview = new vscode.EventEmitter(); + public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event; + + private _comments: readonly IComment[] | undefined; + private _onDidChangeComments: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeComments: vscode.Event = this._onDidChangeComments.event; + + // Whether the pull request is currently checked out locally + private _isActive: boolean; + public get isActive(): boolean { + return this._isActive; + } + public set isActive(isActive: boolean) { + this._isActive = isActive; + } + + _telemetry: ITelemetry; + + constructor( + private readonly credentialStore: CredentialStore, + telemetry: ITelemetry, + githubRepository: GitHubRepository, + remote: Remote, + item: PullRequest, + isActive?: boolean, + ) { + super(githubRepository, remote, item, true); + + this._telemetry = telemetry; + this.isActive = !!isActive; + + this._showChangesSinceReview = false; + + this.update(item); + } + + public clear() { + this.comments = []; + this._reviewThreadsCacheInitialized = false; + this._reviewThreadsCache = []; + } + + public async initializeReviewThreadCache(): Promise { + await this.getReviewThreads(); + this._reviewThreadsCacheInitialized = true; + } + + public get reviewThreadsCache(): IReviewThread[] { + return this._reviewThreadsCache; + } + + public get reviewThreadsCacheReady(): boolean { + return this._reviewThreadsCacheInitialized; + } + + public get isMerged(): boolean { + return this.state === GithubItemStateEnum.Merged; + } + + public get hasPendingReview(): boolean { + return this._hasPendingReview; + } + + public set hasPendingReview(hasPendingReview: boolean) { + if (this._hasPendingReview !== hasPendingReview) { + this._hasPendingReview = hasPendingReview; + this._onDidChangePendingReviewState.fire(this._hasPendingReview); + } + } + + public get showChangesSinceReview() { + return this._showChangesSinceReview; + } + + public set showChangesSinceReview(isChangesSinceReview: boolean) { + if (this._showChangesSinceReview !== isChangesSinceReview) { + this._showChangesSinceReview = isChangesSinceReview; + this._fileChanges.clear(); + this._onDidChangeChangesSinceReview.fire(); + } + } + + get comments(): readonly IComment[] { + return this._comments ?? []; + } + + set comments(comments: readonly IComment[]) { + this._comments = comments; + this._onDidChangeComments.fire(); + } + + get fileChangeViewedState(): FileViewedState { + return this._fileChangeViewedState; + } + + public isRemoteHeadDeleted?: boolean; + public head: GitHubRef | null; + public isRemoteBaseDeleted?: boolean; + public base: GitHubRef; + + protected updateState(state: string) { + if (state.toLowerCase() === 'open') { + this.state = GithubItemStateEnum.Open; + } else if (state.toLowerCase() === 'merged' || this.item.merged) { + this.state = GithubItemStateEnum.Merged; + } else { + this.state = GithubItemStateEnum.Closed; + } + } + + update(item: PullRequest): void { + super.update(item); + this.isDraft = item.isDraft; + this.suggestedReviewers = item.suggestedReviewers; + + if (item.isRemoteHeadDeleted != null) { + this.isRemoteHeadDeleted = item.isRemoteHeadDeleted; + } + if (item.head) { + this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name, item.head.repo.isInOrganization); + } + + if (item.isRemoteBaseDeleted != null) { + this.isRemoteBaseDeleted = item.isRemoteBaseDeleted; + } + if (item.base) { + this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name, item.base.repo.isInOrganization); + } + if (item.mergeQueueEntry !== undefined) { + this.mergeQueueEntry = item.mergeQueueEntry ?? undefined; + } + } + + /** + * Validate if the pull request has a valid HEAD. + * Use only when the method can fail silently, otherwise use `validatePullRequestModel` + */ + isResolved(): this is IResolvedPullRequestModel { + return !!this.head; + } + + /** + * Validate if the pull request has a valid HEAD. Show a warning message to users when the pull request is invalid. + * @param message Human readable action execution failure message. + */ + validatePullRequestModel(message?: string): this is IResolvedPullRequestModel { + if (!!this.head) { + return true; + } + + const reason = vscode.l10n.t('There is no upstream branch for Pull Request #{0}. View it on GitHub for more details', this.number); + + if (message) { + message += `: ${reason}`; + } else { + message = reason; + } + + const openString = vscode.l10n.t('Open on GitHub'); + vscode.window.showWarningMessage(message, openString).then(action => { + if (action && action === openString) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url)); + } + }); + + return false; + } + + /** + * Approve the pull request. + * @param message Optional approval comment text. + */ + async approve(repository: Repository, message?: string): Promise { + // Check that the remote head of the PR branch matches the local head of the PR branch + let remoteHead: string | undefined; + let localHead: string | undefined; + let rejectMessage: string | undefined; + if (this.isActive) { + localHead = repository.state.HEAD?.commit; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please pull the latest changes from the remote branch before approving.'); + } else { + localHead = this.head?.sha; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please refresh the pull request before approving.'); + } + + if (!remoteHead || remoteHead !== localHead) { + return Promise.reject(rejectMessage); + } + + const action: Promise = (await this.getPendingReviewId()) + ? this.submitReview(ReviewEvent.Approve, message) + : this.createReview(ReviewEvent.Approve, message); + + return action.then(x => { + /* __GDPR__ + "pr.approve" : {} + */ + this._telemetry.sendTelemetryEvent('pr.approve'); + this._onDidChangeComments.fire(); + return x; + }); + } + + /** + * Request changes on the pull request. + * @param message Optional comment text to leave with the review. + */ + async requestChanges(message?: string): Promise { + const action: Promise = (await this.getPendingReviewId()) + ? this.submitReview(ReviewEvent.RequestChanges, message) + : this.createReview(ReviewEvent.RequestChanges, message); + + return action.then(x => { + /* __GDPR__ + "pr.requestChanges" : {} + */ + this._telemetry.sendTelemetryEvent('pr.requestChanges'); + this._onDidChangeComments.fire(); + return x; + }); + } + + /** + * Close the pull request. + */ + async close(): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + const ret = await octokit.call(octokit.api.pulls.update, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + state: 'closed', + }); + + /* __GDPR__ + "pr.close" : {} + */ + this._telemetry.sendTelemetryEvent('pr.close'); + + return convertRESTPullRequestToRawPullRequest(ret.data, this.githubRepository); + } + + /** + * Create a new review. + * @param event The type of review to create, an approval, request for changes, or comment. + * @param message The summary comment text. + */ + private async createReview(event: ReviewEvent, message?: string): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + + const { data } = await octokit.call(octokit.api.pulls.createReview, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + event: event, + body: message, + }); + + return convertRESTReviewEvent(data, this.githubRepository); + } + + /** + * Submit an existing review. + * @param event The type of review to create, an approval, request for changes, or comment. + * @param body The summary comment text. + */ + async submitReview(event?: ReviewEvent, body?: string): Promise { + let pendingReviewId = await this.getPendingReviewId(); + const { mutate, schema } = await this.githubRepository.ensure(); + + if (!pendingReviewId && (event === ReviewEvent.Comment)) { + // Create a new review so that we can comment on it. + pendingReviewId = await this.startReview(); + } + + if (pendingReviewId) { + const { data } = await mutate({ + mutation: schema.SubmitReview, + variables: { + id: pendingReviewId, + event: event || ReviewEvent.Comment, + body, + }, + }); + + this.hasPendingReview = false; + await this.updateDraftModeContext(); + const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); + + const threadWithComment = this._reviewThreadsCache.find(thread => + thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, + ); + if (threadWithComment) { + threadWithComment.comments = reviewEvent.comments; + threadWithComment.viewerCanResolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + return reviewEvent; + } else { + throw new Error(`Submitting review failed, no pending review for current pull request: ${this.number}.`); + } + } + + async updateMilestone(id: string): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + const finalId = id === 'null' ? null : id; + + try { + await mutate({ + mutation: schema.UpdatePullRequest, + variables: { + input: { + pullRequestId: this.item.graphNodeId, + milestoneId: finalId, + }, + }, + }); + } catch (err) { + Logger.error(err, PullRequestModel.ID); + } + } + + async addAssignees(assignees: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.issues.addAssignees, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + assignees, + }); + } + + /** + * Query to see if there is an existing review. + */ + async getPendingReviewId(): Promise { + const { query, schema } = await this.githubRepository.ensure(); + const currentUser = await this.githubRepository.getAuthenticatedUser(); + try { + const { data } = await query({ + query: schema.GetPendingReviewId, + variables: { + pullRequestId: this.item.graphNodeId, + author: currentUser, + }, + }); + return data.node.reviews.nodes.length > 0 ? data.node.reviews.nodes[0].id : undefined; + } catch (error) { + return; + } + } + + async getViewerLatestReviewCommit(): Promise<{ sha: string } | undefined> { + Logger.debug(`Fetch viewers latest review commit`, IssueModel.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + + try { + const { data } = await query({ + query: schema.LatestReviewCommit, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository while getting last review commit', PullRequestModel.ID); + } + + return data.repository?.pullRequest.viewerLatestReview ? { + sha: data.repository?.pullRequest.viewerLatestReview.commit.oid, + } : undefined; + } + catch (e) { + return undefined; + } + } + + /** + * Delete an existing in progress review. + */ + async deleteReview(): Promise<{ deletedReviewId: number; deletedReviewComments: IComment[] }> { + const pendingReviewId = await this.getPendingReviewId(); + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DeleteReview, + variables: { + input: { pullRequestReviewId: pendingReviewId }, + }, + }); + + const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview; + + this.hasPendingReview = false; + await this.updateDraftModeContext(); + + this.getReviewThreads(); + + return { + deletedReviewId: databaseId, + deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)), + }; + } + + /** + * Start a new review. + * @param initialComment The comment text and position information to begin the review with + * @param commitId The optional commit id to start the review on. Defaults to using the current head commit. + */ + async startReview(commitId?: string): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.StartReview, + variables: { + input: { + body: '', + pullRequestId: this.item.graphNodeId, + commitOID: commitId || this.head?.sha, + }, + }, + }); + + if (!data) { + throw new Error('Failed to start review'); + } + this.hasPendingReview = true; + this._onDidChangeComments.fire(); + return data.addPullRequestReview.pullRequestReview.id; + } + + /** + * Creates a new review thread, either adding it to an existing pending review, or creating + * a new review. + * @param body The body of the thread's first comment. + * @param commentPath The path to the file being commented on. + * @param startLine The start line on which to add the comment. + * @param endLine The end line on which to add the comment. + * @param side The side the comment should be deleted on, i.e. the original or modified file. + * @param suppressDraftModeUpdate If a draft mode change should event should be suppressed. In the + * case of a single comment add, the review is created and then immediately submitted, so this prevents + * a "Pending" label from flashing on the comment. + * @returns The new review thread object. + */ + async createReviewThread( + body: string, + commentPath: string, + startLine: number | undefined, + endLine: number | undefined, + side: DiffSide, + suppressDraftModeUpdate?: boolean, + ): Promise { + if (!this.validatePullRequestModel('Creating comment failed')) { + return; + } + const pendingReviewId = await this.getPendingReviewId(); + + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddReviewThread, + variables: { + input: { + path: commentPath, + body, + pullRequestId: this.graphNodeId, + pullRequestReviewId: pendingReviewId, + startLine: startLine === endLine ? undefined : startLine, + line: (endLine === undefined) ? 0 : endLine, + side, + subjectType: (startLine === undefined || endLine === undefined) ? SubjectType.FILE : SubjectType.LINE + } + } + }, { mutation: schema.LegacyAddReviewThread, deleteProps: ['subjectType'] }); + + if (!data) { + throw new Error('Creating review thread failed.'); + } + + if (!data.addPullRequestReviewThread.thread) { + throw new Error('File has been deleted.'); + } + + if (!suppressDraftModeUpdate) { + this.hasPendingReview = true; + await this.updateDraftModeContext(); + } + + const thread = data.addPullRequestReviewThread.thread; + const newThread = parseGraphQLReviewThread(thread, this.githubRepository); + this._reviewThreadsCache.push(newThread); + this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); + return newThread; + } + + /** + * Creates a new comment in reply to an existing comment + * @param body The text of the comment to be created + * @param inReplyTo The id of the comment this is in reply to + * @param isSingleComment Whether this is a single comment, i.e. one that + * will be immediately submitted and so should not show a pending label + * @param commitId The commit id the comment was made on + * @returns The new comment + */ + async createCommentReply( + body: string, + inReplyTo: string, + isSingleComment: boolean, + commitId?: string, + ): Promise { + if (!this.validatePullRequestModel('Creating comment failed')) { + return; + } + + let pendingReviewId = await this.getPendingReviewId(); + if (!pendingReviewId) { + pendingReviewId = await this.startReview(commitId); + } + + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddComment, + variables: { + input: { + pullRequestReviewId: pendingReviewId, + body, + inReplyTo, + commitOID: commitId || this.head?.sha, + }, + }, + }); + + if (!data) { + throw new Error('Creating comment reply failed.'); + } + + const { comment } = data.addPullRequestReviewComment; + const newComment = parseGraphQLComment(comment, false, this.githubRepository); + + if (isSingleComment) { + newComment.isDraft = false; + } + + const threadWithComment = this._reviewThreadsCache.find(thread => + thread.comments.some(comment => comment.graphNodeId === inReplyTo), + ); + if (threadWithComment) { + threadWithComment.comments.push(newComment); + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + + return newComment; + } + + /** + * Check whether there is an existing pending review and update the context key to control what comment actions are shown. + */ + async validateDraftMode(): Promise { + const inDraftMode = !!(await this.getPendingReviewId()); + if (inDraftMode !== this.hasPendingReview) { + this.hasPendingReview = inDraftMode; + } + + await this.updateDraftModeContext(); + + return inDraftMode; + } + + private async updateDraftModeContext() { + if (this.isActive) { + await vscode.commands.executeCommand('setContext', 'reviewInDraftMode', this.hasPendingReview); + } + } + + /** + * Edit an existing review comment. + * @param comment The comment to edit + * @param text The new comment text + */ + async editReviewComment(comment: IComment, text: string): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + let threadWithComment = this._reviewThreadsCache.find(thread => + thread.comments.some(c => c.graphNodeId === comment.graphNodeId), + ); + + if (!threadWithComment) { + return this.editIssueComment(comment, text); + } + + const { data } = await mutate({ + mutation: schema.EditComment, + variables: { + input: { + pullRequestReviewCommentId: comment.graphNodeId, + body: text, + }, + }, + }); + + if (!data) { + throw new Error('Editing review comment failed.'); + } + + const newComment = parseGraphQLComment( + data.updatePullRequestReviewComment.pullRequestReviewComment, + !!comment.isResolved, + this.githubRepository + ); + if (threadWithComment) { + const index = threadWithComment.comments.findIndex(c => c.graphNodeId === comment.graphNodeId); + threadWithComment.comments.splice(index, 1, newComment); + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + + return newComment; + } + + /** + * Deletes a review comment. + * @param commentId The comment id to delete + */ + async deleteReviewComment(commentId: string): Promise { + try { + const { octokit, remote } = await this.githubRepository.ensure(); + const id = Number(commentId); + const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id)); + + if (threadIndex === -1) { + this.deleteIssueComment(commentId); + } else { + await octokit.call(octokit.api.pulls.deleteReviewComment, { + owner: remote.owner, + repo: remote.repositoryName, + comment_id: id, + }); + + if (threadIndex > -1) { + const threadWithComment = this._reviewThreadsCache[threadIndex]; + const index = threadWithComment.comments.findIndex(c => c.id === id); + threadWithComment.comments.splice(index, 1); + if (threadWithComment.comments.length === 0) { + this._reviewThreadsCache.splice(threadIndex, 1); + this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); + } else { + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + } + } + } catch (e) { + throw new Error(formatError(e)); + } + } + + /** + * Get existing requests to review. + */ + async getReviewRequests(): Promise<(IAccount | ITeam)[]> { + const githubRepository = this.githubRepository; + const { remote, query, schema } = await githubRepository.ensure(); + + const { data } = await query({ + query: this.credentialStore.isAuthenticatedWithAdditionalScopes(githubRepository.remote.authProviderId) ? schema.GetReviewRequestsAdditionalScopes : schema.GetReviewRequests, + variables: { + number: this.number, + owner: remote.owner, + name: remote.repositoryName + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); + return []; + } + + const reviewers: (IAccount | ITeam)[] = []; + for (const reviewer of data.repository.pullRequest.reviewRequests.nodes) { + if (reviewer.requestedReviewer?.login) { + const account: IAccount = { + login: reviewer.requestedReviewer.login, + url: reviewer.requestedReviewer.url, + avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), + email: reviewer.requestedReviewer.email, + name: reviewer.requestedReviewer.name, + id: reviewer.requestedReviewer.id + }; + reviewers.push(account); + } else if (reviewer.requestedReviewer) { + const team: ITeam = { + name: reviewer.requestedReviewer.name, + url: reviewer.requestedReviewer.url, + avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), + id: reviewer.requestedReviewer.id!, + org: remote.owner, + slug: reviewer.requestedReviewer.slug! + }; + reviewers.push(team); + } + } + return reviewers; + } + + /** + * Add reviewers to a pull request + * @param reviewers A list of GitHub logins + */ + async requestReview(reviewers: string[], teamReviewers: string[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + await mutate({ + mutation: schema.AddReviewers, + variables: { + input: { + pullRequestId: this.graphNodeId, + teamIds: teamReviewers, + userIds: reviewers + }, + }, + }); + } + + /** + * Remove a review request that has not yet been completed + * @param reviewer A GitHub Login + */ + async deleteReviewRequest(reviewers: string[], teamReviewers: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.pulls.removeRequestedReviewers, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + reviewers, + team_reviewers: teamReviewers + }); + } + + async deleteAssignees(assignees: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.issues.removeAssignees, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + assignees, + }); + } + + private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void { + const added: IReviewThread[] = []; + const changed: IReviewThread[] = []; + const removed: IReviewThread[] = []; + + newReviewThreads.forEach(thread => { + const existingThread = oldReviewThreads.find(t => t.id === thread.id); + if (existingThread) { + if (!equals(thread, existingThread)) { + changed.push(thread); + } + } else { + added.push(thread); + } + }); + + oldReviewThreads.forEach(thread => { + if (!newReviewThreads.find(t => t.id === thread.id)) { + removed.push(thread); + } + }); + + this._onDidChangeReviewThreads.fire({ + added, + changed, + removed, + }); + } + + async getReviewThreads(): Promise { + const { remote, query, schema } = await this.githubRepository.ensure(); + let after: string | null = null; + let hasNextPage = false; + const reviewThreads: IReviewThread[] = []; + try { + do { + const { data } = await query({ + query: schema.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after + }, + }, false, { query: schema.LegacyPullRequestComments }); + + reviewThreads.push(...data.repository.pullRequest.reviewThreads.nodes.map(node => { + return parseGraphQLReviewThread(node, this.githubRepository); + })); + + hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; + after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; + } while (hasNextPage && reviewThreads.length < 1000); + + const oldReviewThreads = this._reviewThreadsCache; + this._reviewThreadsCache = reviewThreads; + this.diffThreads(oldReviewThreads, reviewThreads); + return reviewThreads; + } catch (e) { + Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); + return []; + } + } + + /** + * Get all review comments. + */ + async initializeReviewComments(): Promise { + const { remote, query, schema } = await this.githubRepository.ensure(); + let after: string | null = null; + let hasNextPage = false; + const comments: IComment[] = []; + try { + do { + const { data } = await query({ + query: schema.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after, + }, + }, false, { query: schema.LegacyPullRequestComments }); + + comments.push(...data.repository.pullRequest.reviewThreads.nodes + .map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote)) + .reduce((prev, curr) => prev.concat(curr), []) + .sort((a: IComment, b: IComment) => { + return a.createdAt > b.createdAt ? 1 : -1; + })); + + hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; + after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; + } while (hasNextPage && comments.length < 1000); + this.comments = comments; + } catch (e) { + Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); + } + } + + /** + * Get a list of the commits within a pull request. + */ + async getCommits(): Promise { + try { + Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID); + const { remote, octokit } = await this.githubRepository.ensure(); + const commitData = await restPaginate(octokit.api.pulls.listCommits, { + pull_number: this.number, + owner: remote.owner, + repo: remote.repositoryName, + }); + Logger.debug(`Fetch commits of PR #${this.number} - done`, PullRequestModel.ID); + + return commitData; + } catch (e) { + vscode.window.showErrorMessage(`Fetching commits failed: ${formatError(e)}`); + return []; + } + } + + /** + * Get all changed files within a commit + * @param commit The commit + */ + async getCommitChangedFiles( + commit: OctokitCommon.PullsListCommitsResponseData[0], + ): Promise { + try { + Logger.debug( + `Fetch file changes of commit ${commit.sha} in PR #${this.number} - enter`, + PullRequestModel.ID, + ); + const { octokit, remote } = await this.githubRepository.ensure(); + const fullCommit = await octokit.call(octokit.api.repos.getCommit, { + owner: remote.owner, + repo: remote.repositoryName, + ref: commit.sha, + }); + Logger.debug( + `Fetch file changes of commit ${commit.sha} in PR #${this.number} - done`, + PullRequestModel.ID, + ); + + return fullCommit.data.files ?? []; + } catch (e) { + vscode.window.showErrorMessage(`Fetching commit file changes failed: ${formatError(e)}`); + return []; + } + } + + /** + * Gets file content for a file at the specified commit + * @param filePath The file path + * @param commit The commit + */ + async getFile(filePath: string, commit: string) { + const { octokit, remote } = await this.githubRepository.ensure(); + const fileContent = await octokit.call(octokit.api.repos.getContent, { + owner: remote.owner, + repo: remote.repositoryName, + path: filePath, + ref: commit, + }); + + if (Array.isArray(fileContent.data)) { + throw new Error(`Unexpected array response when getting file ${filePath}`); + } + + const contents = (fileContent.data as any).content ?? ''; + const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); + return buff.toString(); + } + + /** + * Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns. + */ + async getTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + + try { + const [{ data }, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([ + query({ + query: schema.TimelineEvents, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }), + this.getViewerLatestReviewCommit(), + this.githubRepository.getAuthenticatedUser(), + this.getReviewThreads() + ]); + + if (data.repository === null) { + Logger.error('Unexpected null repository when fetching timeline', PullRequestModel.ID); + } + + const ret = data.repository?.pullRequest.timelineItems.nodes; + const events = ret ? parseGraphQLTimelineEvents(ret, this.githubRepository) : []; + + this.addReviewTimelineEventComments(events, reviewThreads); + insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); + + return events; + } catch (e) { + console.log(e); + return []; + } + } + + private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void { + interface CommentNode extends IComment { + childComments?: CommentNode[]; + } + + const reviewEvents = events.filter((e): e is CommonReviewEvent => e.event === EventType.Reviewed); + const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []); + + const reviewEventsById = reviewEvents.reduce((index, evt) => { + index[evt.id] = evt; + evt.comments = []; + return index; + }, {} as { [key: number]: CommonReviewEvent }); + + const commentsById = reviewComments.reduce((index, evt) => { + index[evt.id] = evt; + return index; + }, {} as { [key: number]: CommentNode }); + + const roots: CommentNode[] = []; + let i = reviewComments.length; + while (i-- > 0) { + const c: CommentNode = reviewComments[i]; + if (!c.inReplyToId) { + roots.unshift(c); + continue; + } + const parent = commentsById[c.inReplyToId]; + parent.childComments = parent.childComments || []; + parent.childComments = [c, ...(c.childComments || []), ...parent.childComments]; + } + + roots.forEach(c => { + const review = reviewEventsById[c.pullRequestReviewId!]; + if (review) { + review.comments = review.comments.concat(c).concat(c.childComments || []); + } + }); + + reviewThreads.forEach(thread => { + if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) { + return; + } + const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; + prReviewThreadEvent.reviewThread = { + threadId: thread.id, + canResolve: thread.viewerCanResolve, + canUnresolve: thread.viewerCanUnresolve, + isResolved: thread.isResolved + }; + + }); + + const pendingReview = reviewEvents.filter(r => r.state.toLowerCase() === 'pending')[0]; + if (pendingReview) { + // Ensures that pending comments made in reply to other reviews are included for the pending review + pendingReview.comments = reviewComments.filter(c => c.isDraft); + } + } + + /** + * Get the status checks of the pull request, those for the last commit. + */ + async getStatusChecks(): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + return this.githubRepository.getStatusChecks(this.number); + } + + static async openChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel) { + const isCurrentPR = folderManager.activePullRequest?.number === pullRequestModel.number; + const changes = pullRequestModel.fileChanges.size > 0 ? pullRequestModel.fileChanges.values() : await pullRequestModel.getFileChangesInfo(); + const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; + + for (const change of changes) { + let changeModel; + if (change instanceof SlimFileChange) { + changeModel = new RemoteFileChangeModel(folderManager, change, pullRequestModel); + } else { + changeModel = new InMemFileChangeModel(folderManager, pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), change, isCurrentPR, pullRequestModel.mergeBase!); + } + args.push([changeModel.filePath, changeModel.parentFilePath, changeModel.filePath]); + } + + /* __GDPR__ + "pr.openChanges" : {} + */ + folderManager.telemetry.sendTelemetryEvent('pr.openChanges'); + return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); + } + + static async openDiffFromComment( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + comment: IComment, + ): Promise { + const contentChanges = await pullRequestModel.getFileChangesInfo(); + const change = contentChanges.find( + fileChange => fileChange.fileName === comment.path || fileChange.previousFileName === comment.path, + ); + if (!change) { + throw new Error(`Can't find matching file`); + } + + const pathSegments = comment.path!.split('/'); + const line = (comment.diffHunks && comment.diffHunks.length > 0) ? comment.diffHunks[0].newLineNumber : undefined; + this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1], line); + } + + static async openFirstDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ) { + const contentChanges = await pullRequestModel.getFileChangesInfo(); + if (!contentChanges.length) { + return; + } + + const firstChange = contentChanges[0]; + this.openDiff(folderManager, pullRequestModel, firstChange, firstChange.fileName); + } + + static async openDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + change: SlimFileChange | InMemFileChange, + diffTitle: string, + line?: number + ): Promise { + let headUri, baseUri: vscode.Uri; + if (!pullRequestModel.equals(folderManager.activePullRequest)) { + const headCommit = pullRequestModel.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + headUri = toPRUri( + vscode.Uri.file(resolvePath(folderManager.repository.rootUri, change.fileName)), + pullRequestModel, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + baseUri = toPRUri( + vscode.Uri.file(resolvePath(folderManager.repository.rootUri, parentFileName)), + pullRequestModel, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } else { + const uri = vscode.Uri.file(path.resolve(folderManager.repository.rootUri.fsPath, change.fileName)); + + headUri = + change.status === GitChangeType.DELETE + ? toReviewUri( + uri, + undefined, + undefined, + '', + false, + { base: false }, + folderManager.repository.rootUri, + ) + : uri; + + const mergeBase = pullRequestModel.mergeBase || pullRequestModel.base.sha; + baseUri = toReviewUri( + uri, + change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, + undefined, + change.status === GitChangeType.ADD ? '' : mergeBase, + false, + { base: true }, + folderManager.repository.rootUri, + ); + } + + vscode.commands.executeCommand( + 'vscode.diff', + baseUri, + headUri, + `${diffTitle} (Pull Request)`, + line ? { selection: { start: { line, character: 0 }, end: { line, character: 0 } } } : {}, + ); + } + + private _fileChanges: Map = new Map(); + get fileChanges(): Map { + return this._fileChanges; + } + + async getFileChangesInfo() { + this._fileChanges.clear(); + const data = await this.getRawFileChangesInfo(); + const mergebase = this.mergeBase || this.base.sha; + const parsed = await parseDiff(data, mergebase); + parsed.forEach(fileChange => { + this._fileChanges.set(fileChange.fileName, fileChange); + }); + return parsed; + } + + /** + * List the changed files in a pull request. + */ + private async getRawFileChangesInfo(): Promise { + Logger.debug( + `Fetch file changes, base, head and merge base of PR #${this.number} - enter`, + PullRequestModel.ID, + ); + const githubRepository = this.githubRepository; + const { octokit, remote } = await githubRepository.ensure(); + + if (!this.base) { + const info = await octokit.call(octokit.api.pulls.get, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + }); + this.update(convertRESTPullRequestToRawPullRequest(info.data, githubRepository)); + } + + let compareWithBaseRef = this.base.sha; + const latestReview = await this.getViewerLatestReviewCommit(); + const oldHasChangesSinceReview = this.hasChangesSinceLastReview; + this.hasChangesSinceLastReview = latestReview !== undefined && this.head?.sha !== latestReview.sha; + + if (this._showChangesSinceReview && this.hasChangesSinceLastReview && latestReview != undefined) { + compareWithBaseRef = latestReview.sha; + } + + if (this.item.merged) { + const response = await restPaginate(octokit.api.pulls.listFiles, { + repo: remote.repositoryName, + owner: remote.owner, + pull_number: this.number, + }); + + // Use the original base to compare against for merged PRs + this.mergeBase = this.base.sha; + + return response; + } + + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef}`, + head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`, + }); + + this.mergeBase = data.merge_base_commit.sha; + + const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100; + let files: IRawFileChange[] = []; + + if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { + // compareCommits will return a maximum of 100 changed files + // If we have (maybe) more than that, we'll need to fetch them with listFiles API call + Logger.debug( + `More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed, fetching all file changes of PR #${this.number}`, + PullRequestModel.ID, + ); + files = await restPaginate(octokit.api.pulls.listFiles, { + owner: this.base.repositoryCloneUrl.owner, + pull_number: this.number, + repo: remote.repositoryName, + }); + } else { + // if we're under the limit, just use the result from compareCommits, don't make additional API calls. + files = data.files ? data.files as IRawFileChange[] : []; + } + + if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) { + this._onDidChangeChangesSinceReview.fire(); + } + + Logger.debug( + `Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, + PullRequestModel.ID, + ); + return files; + } + + get autoMerge(): boolean { + return !!this.item.autoMerge; + } + + get autoMergeMethod(): MergeMethod | undefined { + return this.item.autoMergeMethod; + } + + get allowAutoMerge(): boolean { + return !!this.item.allowAutoMerge; + } + + get mergeCommitMeta(): { title: string; description: string } | undefined { + return this.item.mergeCommitMeta; + } + + get squashCommitMeta(): { title: string; description: string } | undefined { + return this.item.squashCommitMeta; + } + + /** + * Get the current mergeability of the pull request. + */ + async getMergeability(): Promise { + try { + Logger.debug(`Fetch pull request mergeability ${this.number} - enter`, PullRequestModel.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + + const { data } = await query({ + query: schema.PullRequestMergeability, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + if (data.repository === null) { + Logger.error('Unexpected null repository while getting mergeability', PullRequestModel.ID); + } + + Logger.debug(`Fetch pull request mergeability ${this.number} - done`, PullRequestModel.ID); + const mergeability = parseMergeability(data.repository?.pullRequest.mergeable, data.repository?.pullRequest.mergeStateStatus); + this.item.mergeable = mergeability; + return mergeability; + } catch (e) { + Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID); + return PullRequestMergeability.Unknown; + } + } + + /** + * Set a draft pull request as ready to be reviewed. + */ + async setReadyForReview(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.ReadyForReview, + variables: { + input: { + pullRequestId: this.graphNodeId, + }, + }, + }); + + /* __GDPR__ + "pr.readyForReview.success" : {} + */ + this._telemetry.sendTelemetryEvent('pr.readyForReview.success'); + + return data!.markPullRequestReadyForReview.pullRequest.isDraft; + } catch (e) { + /* __GDPR__ + "pr.readyForReview.failure" : {} + */ + this._telemetry.sendTelemetryErrorEvent('pr.readyForReview.failure'); + throw e; + } + } + + private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) { + const reviewThread = this._reviewThreadsCache.find(thread => + thread.comments.some(c => c.graphNodeId === graphNodeId), + ); + if (reviewThread) { + const updatedComment = reviewThread.comments.find(c => c.graphNodeId === graphNodeId); + if (updatedComment) { + updatedComment.reactions = parseGraphQLReaction(reactionGroups); + this._onDidChangeReviewThreads.fire({ added: [], changed: [reviewThread], removed: [] }); + } + } + } + + async addCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise { + const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { + prev[curr.label] = curr.title; + return prev; + }, {} as { [key: string]: string }); + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddReaction, + variables: { + input: { + subjectId: graphNodeId, + content: reactionEmojiToContent[reaction.label!], + }, + }, + }); + + if (!data) { + throw new Error('Add comment reaction failed.'); + } + + const reactionGroups = data.addReaction.subject.reactionGroups; + this.updateCommentReactions(graphNodeId, reactionGroups); + + return data; + } + + async deleteCommentReaction( + graphNodeId: string, + reaction: vscode.CommentReaction, + ): Promise { + const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { + prev[curr.label] = curr.title; + return prev; + }, {} as { [key: string]: string }); + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DeleteReaction, + variables: { + input: { + subjectId: graphNodeId, + content: reactionEmojiToContent[reaction.label!], + }, + }, + }); + + if (!data) { + throw new Error('Delete comment reaction failed.'); + } + + const reactionGroups = data.removeReaction.subject.reactionGroups; + this.updateCommentReactions(graphNodeId, reactionGroups); + + return data; + } + + private undoOptimisticResolveState(oldThread: IReviewThread | undefined) { + if (oldThread) { + oldThread.isResolved = !oldThread.isResolved; + oldThread.viewerCanResolve = !oldThread.viewerCanResolve; + oldThread.viewerCanUnresolve = !oldThread.viewerCanUnresolve; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + } + + async resolveReviewThread(threadId: string): Promise { + const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + + try { + Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanResolve) { + oldThread.isResolved = true; + oldThread.viewerCanResolve = false; + oldThread.viewerCanUnresolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.ResolveReviewThread, + variables: { + input: { + threadId, + }, + }, + }, { mutation: schema.LegacyResolveReviewThread, deleteProps: [] }); + + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Resolve review thread failed.'); + } + + const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + if (index > -1) { + const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Resolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); + } + } + + async unresolveReviewThread(threadId: string): Promise { + const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + + try { + Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanUnresolve) { + oldThread.isResolved = false; + oldThread.viewerCanUnresolve = false; + oldThread.viewerCanResolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.UnresolveReviewThread, + variables: { + input: { + threadId, + }, + }, + }, { mutation: schema.LegacyUnresolveReviewThread, deleteProps: [] }); + + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Unresolve review thread failed.'); + } + + const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + if (index > -1) { + const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Unresolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); + } + } + + async enableAutoMerge(mergeMethod: MergeMethod): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.EnablePullRequestAutoMerge, + variables: { + input: { + mergeMethod: mergeMethod.toUpperCase(), + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Enable auto-merge failed.'); + } + this.item.autoMerge = true; + this.item.autoMergeMethod = mergeMethod; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } + } + } + + async disableAutoMerge(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DisablePullRequestAutoMerge, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Disable auto-merge failed.'); + } + this.item.autoMerge = false; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } + } + } + + async dequeuePullRequest(): Promise { + Logger.debug(`Dequeue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.DequeuePullRequest) { + return false; + } + try { + await mutate({ + mutation: schema.DequeuePullRequest, + variables: { + input: { + id: this.graphNodeId + } + } + }); + + Logger.debug(`Dequeue pull request ${this.number} - done`, GitHubRepository.ID); + this.mergeQueueEntry = undefined; + return true; + } catch (e) { + Logger.error(`Dequeueing pull request failed: ${e}`, GitHubRepository.ID); + return false; + } + } + + async enqueuePullRequest(): Promise { + Logger.debug(`Enqueue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.EnqueuePullRequest) { + return; + } + try { + const { data } = await mutate({ + mutation: schema.EnqueuePullRequest, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + Logger.debug(`Enqueue pull request ${this.number} - done`, GitHubRepository.ID); + const temp = parseMergeQueueEntry(data?.enqueuePullRequest.mergeQueueEntry) ?? undefined; + return temp; + } catch (e) { + Logger.error(`Enqueuing pull request failed: ${e}`, GitHubRepository.ID); + } + } + + async initializePullRequestFileViewState(): Promise { + const { query, schema, remote } = await this.githubRepository.ensure(); + + const changed: { fileName: string, viewed: ViewedState }[] = []; + let after: string | null = null; + let hasNextPage = false; + + do { + const { data } = await query({ + query: schema.PullRequestFiles, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after: after, + }, + }); + + data.repository.pullRequest.files.nodes.forEach(n => { + if (this._fileChangeViewedState[n.path] !== n.viewerViewedState) { + changed.push({ fileName: n.path, viewed: n.viewerViewedState }); + } + // No event for setting the file viewed state here. + // Instead, wait until all the changes have been made and set the context at the end. + this.setFileViewedState(n.path, n.viewerViewedState, false); + }); + + hasNextPage = data.repository.pullRequest.files.pageInfo.hasNextPage; + after = data.repository.pullRequest.files.pageInfo.endCursor; + } while (hasNextPage); + + if (changed.length) { + this._onDidChangeFileViewedState.fire({ changed }); + } + } + + async markFiles(filePathOrSubpaths: string[], event: boolean, state: 'viewed' | 'unviewed'): Promise { + const { mutate } = await this.githubRepository.ensure(); + const pullRequestId = this.graphNodeId; + + const allFilenames = filePathOrSubpaths + .map((f) => + f.startsWith(this.githubRepository.rootUri.path) + ? f.substring(this.githubRepository.rootUri.path.length + 1) + : f + ); + + const mutationName = state === 'viewed' + ? 'markFileAsViewed' + : 'unmarkFileAsViewed'; + + // We only ever send 100 mutations at once. Any more than this and + // we risk a timeout from GitHub. + for (let i = 0; i < allFilenames.length; i += BATCH_SIZE) { + const batch = allFilenames.slice(i, i + BATCH_SIZE); + // See below for an example of what a mutation produced by this + // will look like + const mutation = gql`mutation Batch${mutationName}{ + ${batch.map((filename, i) => + `alias${i}: ${mutationName}( + input: {path: "${filename}", pullRequestId: "${pullRequestId}"} + ) { clientMutationId } + ` + )} + }`; + await mutate({ mutation }); + } + + // mutation BatchUnmarkFileAsViewedInline { + // alias0: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/A.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // alias1: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/B.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // } + + filePathOrSubpaths.forEach(path => this.setFileViewedState(path, state === 'viewed' ? ViewedState.VIEWED : ViewedState.UNVIEWED, event)); + } + + async unmarkAllFilesAsViewed(): Promise { + return this.markFiles(Array.from(this.fileChanges.keys()), true, 'unviewed'); + } + + private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) { + const uri = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath); + const filePath = (this.githubRepository.rootUri.scheme === Schemes.VscodeVfs) ? uri.path : uri.fsPath; + switch (viewedState) { + case ViewedState.DISMISSED: { + this._viewedFiles.delete(filePath); + this._unviewedFiles.delete(filePath); + break; + } + case ViewedState.UNVIEWED: { + this._viewedFiles.delete(filePath); + this._unviewedFiles.add(filePath); + break; + } + case ViewedState.VIEWED: { + this._viewedFiles.add(filePath); + this._unviewedFiles.delete(filePath); + } + } + this._fileChangeViewedState[fileSubpath] = viewedState; + if (event) { + this._onDidChangeFileViewedState.fire({ changed: [{ fileName: fileSubpath, viewed: viewedState }] }); + } + } + + public getViewedFileStates() { + return { + viewed: this._viewedFiles, + unviewed: this._unviewedFiles + }; + } +} diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index feb981fc4b..700840f7fe 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -1,842 +1,843 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { asPromise, dispose, formatError } from '../common/utils'; -import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { - GithubItemStateEnum, - IAccount, - IMilestone, - IProject, - IProjectItem, - isTeam, - ITeam, - MergeMethod, - MergeMethodsAvailability, - reviewerId, - ReviewEvent, - ReviewState, -} from './interface'; -import { IssueOverviewPanel } from './issueOverview'; -import { PullRequestModel } from './pullRequestModel'; -import { PullRequestView } from './pullRequestOverviewCommon'; -import { getAssigneesQuickPickItems, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; -import { isInCodespaces, parseReviewers, vscodeDevPrLink } from './utils'; -import { ProjectItemsReply, PullRequest, ReviewType } from './views'; - -export class PullRequestOverviewPanel extends IssueOverviewPanel { - public static ID: string = 'PullRequestOverviewPanel'; - /** - * Track the currently panel. Only allow a single panel to exist at a time. - */ - public static currentPanel?: PullRequestOverviewPanel; - - private _repositoryDefaultBranch: string; - private _existingReviewers: ReviewState[] = []; - private _teamsCount = 0; - - private _prListeners: vscode.Disposable[] = []; - private _isUpdating: boolean = false; - - public static async createOrShow( - extensionUri: vscode.Uri, - folderRepositoryManager: FolderRepositoryManager, - issue: PullRequestModel, - toTheSide: boolean = false, - preserveFocus: boolean = true - ) { - const activeColumn = toTheSide - ? vscode.ViewColumn.Beside - : vscode.window.activeTextEditor - ? vscode.window.activeTextEditor.viewColumn - : vscode.ViewColumn.One; - - // If we already have a panel, show it. - // Otherwise, create a new panel. - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.currentPanel._panel.reveal(activeColumn, preserveFocus); - } else { - const title = `Pull Request #${issue.number.toString()}`; - PullRequestOverviewPanel.currentPanel = new PullRequestOverviewPanel( - extensionUri, - activeColumn || vscode.ViewColumn.Active, - title, - folderRepositoryManager, - ); - } - - await PullRequestOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); - } - - protected set _currentPanel(panel: PullRequestOverviewPanel | undefined) { - PullRequestOverviewPanel.currentPanel = panel; - } - - public static refresh(): void { - if (this.currentPanel) { - this.currentPanel.refreshPanel(); - } - } - - public static scrollToReview(): void { - if (this.currentPanel) { - this.currentPanel._postMessage({ command: 'pr.scrollToPendingReview' }); - } - } - - protected constructor( - extensionUri: vscode.Uri, - column: vscode.ViewColumn, - title: string, - folderRepositoryManager: FolderRepositoryManager, - ) { - super(extensionUri, column, title, folderRepositoryManager, PULL_REQUEST_OVERVIEW_VIEW_TYPE); - - this.registerPrListeners(); - onDidUpdatePR( - pr => { - if (pr) { - this._item.update(pr); - } - - this._postMessage({ - command: 'update-state', - state: this._item.state, - }); - }, - null, - this._disposables, - ); - - this._disposables.push( - folderRepositoryManager.onDidMergePullRequest(_ => { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); - }), - ); - this._disposables.push(folderRepositoryManager.credentialStore.onDidUpgradeSession(() => { - this.updatePullRequest(this._item); - })); - - this._disposables.push(vscode.commands.registerCommand('review.approveDescription', (e) => this.approvePullRequestCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.commentDescription', (e) => this.submitReviewCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.requestChangesDescription', (e) => this.requestChangesCommand(e))); - this._disposables.push(vscode.commands.registerCommand('review.approveOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - })); - this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - })); - } - - registerPrListeners() { - dispose(this._prListeners); - this._prListeners = []; - this._prListeners.push(this._folderRepositoryManager.onDidChangeActivePullRequest(_ => { - if (this._folderRepositoryManager && this._item) { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); - this._postMessage({ - command: 'pr.update-checkout-status', - isCurrentlyCheckedOut, - }); - } - })); - - if (this._item) { - this._prListeners.push(this._item.onDidChangeComments(() => { - if (!this._isUpdating) { - this.refreshPanel(); - } - })); - } - } - - /** - * Find currently configured user's review status for the current PR - * @param reviewers All the reviewers who have been requested to review the current PR - * @param pullRequestModel Model of the PR - */ - private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { - const review = reviewers.find(r => reviewerId(r.reviewer) === currentUser.login); - // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user - return review?.state; - } - - private async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - return Promise.all([ - this._folderRepositoryManager.resolvePullRequest( - pullRequestModel.remote.owner, - pullRequestModel.remote.repositoryName, - pullRequestModel.number, - ), - pullRequestModel.getTimelineEvents(), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), - pullRequestModel.getStatusChecks(), - pullRequestModel.getReviewRequests(), - this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), - this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), - this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), - pullRequestModel.canEdit(), - this._folderRepositoryManager.getOrgTeamsCount(pullRequestModel.githubRepository), - this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName)]) - .then(result => { - const [ - pullRequest, - timelineEvents, - defaultBranch, - status, - requestedReviewers, - repositoryAccess, - branchInfo, - currentUser, - viewerCanEdit, - orgTeamsCount, - mergeQueueMethod - ] = result; - if (!pullRequest) { - throw new Error( - `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, - ); - } - - this._item = pullRequest; - this.registerPrListeners(); - this._repositoryDefaultBranch = defaultBranch!; - this._teamsCount = orgTeamsCount; - this.setPanelTitle(`Pull Request #${pullRequestModel.number.toString()}`); - - const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); - const hasWritePermission = repositoryAccess!.hasWritePermission; - const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || viewerCanEdit; - - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); - this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author); - - const isCrossRepository = - pullRequest.base && - !!pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - - const continueOnGitHub = isCrossRepository && isInCodespaces(); - const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); - Logger.debug('pr.initialize', PullRequestOverviewPanel.ID); - const context: Partial = { - number: pullRequest.number, - title: pullRequest.title, - titleHTML: pullRequest.titleHTML, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - id: pullRequest.author.id, - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - }, - state: pullRequest.state, - events: timelineEvents, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - repositoryDefaultBranch: defaultBranch, - canEdit: canEdit, - hasWritePermission, - status: status[0], - reviewRequirement: status[1], - mergeable: pullRequest.item.mergeable, - reviewers: this._existingReviewers, - isDraft: pullRequest.isDraft, - mergeMethodsAvailability, - defaultMergeMethod, - autoMerge: pullRequest.autoMerge, - allowAutoMerge: pullRequest.allowAutoMerge, - autoMergeMethod: pullRequest.autoMergeMethod, - mergeQueueMethod: mergeQueueMethod, - mergeQueueEntry: pullRequest.mergeQueueEntry, - mergeCommitMeta: pullRequest.mergeCommitMeta, - squashCommitMeta: pullRequest.squashCommitMeta, - isIssue: false, - projectItems: pullRequest.item.projectItems, - milestone: pullRequest.milestone, - assignees: pullRequest.assignees, - continueOnGitHub, - isAuthor: currentUser.login === pullRequest.author.login, - currentUserReviewState: reviewState, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, - isEnterprise: pullRequest.githubRepository.remote.isEnterprise - }; - this._postMessage({ - command: 'pr.initialize', - pullrequest: context - }); - if (pullRequest.isResolved()) { - this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); - } - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); - }); - } - - public async update( - folderRepositoryManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - ): Promise { - if (this._folderRepositoryManager !== folderRepositoryManager) { - this._folderRepositoryManager = folderRepositoryManager; - this.registerPrListeners(); - } - - this._postMessage({ - command: 'set-scroll', - scrollPosition: this._scrollPosition, - }); - - if (!this._item || (this._item.number !== pullRequestModel.number) || !this._panel.webview.html) { - this._panel.webview.html = this.getHtmlForWebview(pullRequestModel.number.toString()); - } - - return this.updatePullRequest(pullRequestModel); - } - - protected async _onDidReceiveMessage(message: IRequestMessage) { - const result = await super._onDidReceiveMessage(message); - if (result !== this.MESSAGE_UNHANDLED) { - return; - } - switch (message.command) { - case 'pr.checkout': - return this.checkoutPullRequest(message); - case 'pr.merge': - return this.mergePullRequest(message); - case 'pr.deleteBranch': - return this.deleteBranch(message); - case 'pr.readyForReview': - return this.setReadyForReview(message); - case 'pr.approve': - return this.approvePullRequestMessage(message); - case 'pr.request-changes': - return this.requestChangesMessage(message); - case 'pr.submit': - return this.submitReviewMessage(message); - case 'pr.checkout-default-branch': - return this.checkoutDefaultBranch(message); - case 'pr.apply-patch': - return this.applyPatch(message); - case 'pr.open-diff': - return this.openDiff(message); - case 'pr.resolve-comment-thread': - return this.resolveCommentThread(message); - case 'pr.checkMergeability': - return this._replyMessage(message, await this._item.getMergeability()); - case 'pr.change-reviewers': - return this.changeReviewers(message); - case 'pr.remove-milestone': - return this.removeMilestone(message); - case 'pr.add-milestone': - return this.addMilestone(message); - case 'pr.change-projects': - return this.changeProjects(message); - case 'pr.remove-project': - return this.removeProject(message); - case 'pr.change-assignees': - return this.changeAssignees(message); - case 'pr.add-assignee-yourself': - return this.addAssigneeYourself(message); - case 'pr.copy-prlink': - return this.copyPrLink(); - case 'pr.copy-vscodedevlink': - return this.copyVscodeDevLink(); - case 'pr.openOnGitHub': - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); - case 'pr.update-automerge': - return this.updateAutoMerge(message); - case 'pr.dequeue': - return this.dequeue(message); - case 'pr.enqueue': - return this.enqueue(message); - case 'pr.gotoChangesSinceReview': - this.gotoChangesSinceReview(); - break; - case 'pr.re-request-review': - this.reRequestReview(message); - break; - } - } - - private gotoChangesSinceReview() { - this._item.showChangesSinceReview = true; - } - - private async changeReviewers(message: IRequestMessage): Promise { - let quickPick: vscode.QuickPick | undefined; - - try { - quickPick = await reviewersQuickPick(this._folderRepositoryManager, this._item.remote.remoteName, this._item.base.isInOrganization, this._teamsCount, this._item.author, this._existingReviewers, this._item.suggestedReviewers); - quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; - }); - const hidePromise = asPromise(quickPick.onDidHide); - const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); - quickPick.busy = true; - - if (allReviewers) { - const newUserReviewers: string[] = []; - const newTeamReviewers: string[] = []; - allReviewers.forEach(reviewer => { - const newReviewers = isTeam(reviewer.user) ? newTeamReviewers : newUserReviewers; - newReviewers.push(reviewer.user.id); - }); - - const removedUserReviewers: string[] = []; - const removedTeamReviewers: string[] = []; - this._existingReviewers.forEach(existing => { - let newReviewers: string[] = isTeam(existing.reviewer) ? newTeamReviewers : newUserReviewers; - let removedReviewers: string[] = isTeam(existing.reviewer) ? removedTeamReviewers : removedUserReviewers; - if (!newReviewers.find(newTeamReviewer => newTeamReviewer === existing.reviewer.id)) { - removedReviewers.push(existing.reviewer.id); - } - }); - - await this._item.requestReview(newUserReviewers, newTeamReviewers); - await this._item.deleteReviewRequest(removedUserReviewers, removedTeamReviewers); - const addedReviewers: ReviewState[] = allReviewers.map(selected => { - return { - reviewer: selected.user, - state: 'REQUESTED', - }; - }); - - this._existingReviewers = addedReviewers; - await this._replyMessage(message, { - reviewers: addedReviewers, - }); - } - } catch (e) { - Logger.error(formatError(e)); - vscode.window.showErrorMessage(formatError(e)); - } finally { - quickPick?.hide(); - quickPick?.dispose(); - } - } - - private async addMilestone(message: IRequestMessage): Promise { - return getMilestoneFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.milestone, (milestone) => this.updateMilestone(milestone, message)); - } - - private async updateMilestone(milestone: IMilestone | undefined, message: IRequestMessage) { - if (!milestone) { - return this.removeMilestone(message); - } - await this._item.updateMilestone(milestone.id); - this._replyMessage(message, { - added: milestone, - }); - } - - private async removeMilestone(message: IRequestMessage): Promise { - try { - await this._item.updateMilestone('null'); - this._replyMessage(message, {}); - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } - } - - private async changeProjects(message: IRequestMessage): Promise { - return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.remote.remoteName, this._item.base.isInOrganization, this._item.item.projectItems, (project) => this.updateProjects(project, message)); - } - - private async updateProjects(projects: IProject[] | undefined, message: IRequestMessage) { - if (projects) { - const newProjects = await this._item.updateProjects(projects); - const projectItemsReply: ProjectItemsReply = { - projectItems: newProjects, - }; - return this._replyMessage(message, projectItemsReply); - } - } - - private async removeProject(message: IRequestMessage): Promise { - await this._item.removeProjects([message.args]); - return this._replyMessage(message, {}); - } - - private async changeAssignees(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); - - try { - quickPick.busy = true; - quickPick.canSelectMany = true; - quickPick.matchOnDescription = true; - quickPick.show(); - quickPick.items = await getAssigneesQuickPickItems(this._folderRepositoryManager, this._item.remote.remoteName, this._item.assignees ?? [], this._item); - quickPick.selectedItems = quickPick.items.filter(item => item.picked); - - quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount })[] | undefined; - }); - const hidePromise = asPromise(quickPick.onDidHide); - const allAssignees = await Promise.race<(vscode.QuickPickItem & { user: IAccount })[] | void>([acceptPromise, hidePromise]); - quickPick.busy = true; - - if (allAssignees) { - const newAssignees: IAccount[] = allAssignees.map(item => item.user); - const removeAssignees: IAccount[] = this._item.assignees?.filter(currentAssignee => !newAssignees.find(newAssignee => newAssignee.login === currentAssignee.login)) ?? []; - this._item.assignees = newAssignees; - - await this._item.addAssignees(newAssignees.map(assignee => assignee.login)); - await this._item.deleteAssignees(removeAssignees.map(assignee => assignee.login)); - await this._replyMessage(message, { - assignees: newAssignees, - }); - } - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } finally { - quickPick.hide(); - quickPick.dispose(); - } - } - - private async addAssigneeYourself(message: IRequestMessage): Promise { - try { - const currentUser = await this._folderRepositoryManager.getCurrentUser(); - const alreadyAssigned = this._item.assignees?.find(user => user.login === currentUser.login); - if (!alreadyAssigned) { - this._item.assignees = this._item.assignees?.concat(currentUser); - await this._item.addAssignees([currentUser.login]); - } - this._replyMessage(message, { - assignees: this._item.assignees, - }); - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } - } - - private async applyPatch(message: IRequestMessage<{ comment: IComment }>): Promise { - try { - const comment = message.args.comment; - const regex = /```diff\n([\s\S]*)\n```/g; - const matches = regex.exec(comment.body); - - const tempUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, '.git', `${comment.id}.diff`); - - const encoder = new TextEncoder(); - - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(matches![1])); - await this._folderRepositoryManager.repository.apply(tempUri.fsPath); - await vscode.workspace.fs.delete(tempUri); - vscode.window.showInformationMessage('Patch applied!'); - } catch (e) { - Logger.error(`Applying patch failed: ${e}`, PullRequestOverviewPanel.ID); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); - } - } - - private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { - try { - const comment = message.args.comment; - return PullRequestModel.openDiffFromComment(this._folderRepositoryManager, this._item, comment); - } catch (e) { - Logger.error(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); - } - } - - private async resolveCommentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) { - try { - if (message.args.toResolve) { - await this._item.resolveReviewThread(message.args.threadId); - } - else { - await this._item.unresolveReviewThread(message.args.threadId); - } - const timelineEvents = await this._item.getTimelineEvents(); - this._replyMessage(message, timelineEvents); - } catch (e) { - vscode.window.showErrorMessage(e); - this._replyMessage(message, undefined); - } - } - - private checkoutPullRequest(message: IRequestMessage): void { - vscode.commands.executeCommand('pr.pick', this._item).then( - () => { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); - this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); - }, - () => { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); - this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); - }, - ); - } - - private mergePullRequest( - message: IRequestMessage<{ title: string | undefined; description: string | undefined; method: 'merge' | 'squash' | 'rebase' }>, - ): void { - const { title, description, method } = message.args; - this._folderRepositoryManager - .mergePullRequest(this._item, title, description, method) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); - - if (!result.merged) { - vscode.window.showErrorMessage(`Merging PR failed: ${result.message}`); - } - - this._replyMessage(message, { - state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); - this._throwError(message, {}); - }); - } - - private async deleteBranch(message: IRequestMessage) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); - if (result.isReply) { - this._replyMessage(message, result.message); - } else { - this.refreshPanel(); - this._postMessage(result.message); - } - } - - private setReadyForReview(message: IRequestMessage<{}>): void { - this._item - .setReadyForReview() - .then(isDraft => { - vscode.commands.executeCommand('pr.refreshList'); - - this._replyMessage(message, { isDraft }); - }) - .catch(e => { - vscode.window.showErrorMessage(`Unable to set PR ready for review. ${formatError(e)}`); - this._throwError(message, {}); - }); - } - - private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(message.args); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); - } - } - - private updateReviewers(review?: CommonReviewEvent): void { - if (review) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === (reviewer.reviewer as IAccount).login, - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } - } - } - - private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { - const submittingMessage = { - command: 'pr.submitting-review', - lastReviewType: reviewType - }; - this._postMessage(submittingMessage); - try { - const review = await action(context.body); - this.updateReviewers(review); - const reviewMessage = { - command: 'pr.append-review', - review, - reviewers: this._existingReviewers - }; - await this._postMessage(reviewMessage); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(undefined, `${formatError(e)}`); - } finally { - this._postMessage({ command: 'pr.append-review' }); - } - } - - private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { - try { - const review = await action(message.args); - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); - } - } - - private approvePullRequest(body: string): Promise { - return this._item.approve(this._folderRepositoryManager.repository, body); - } - - private approvePullRequestMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); - } - - private approvePullRequestCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); - } - - private requestChanges(body: string): Promise { - return this._item.requestChanges(body); - } - - private requestChangesCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); - } - - private requestChangesMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.requestChanges(body)); - } - - private submitReview(body: string): Promise { - return this._item.submitReview(ReviewEvent.Comment, body); - } - - private submitReviewCommand(context: { body: string }) { - return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); - } - - private submitReviewMessage(message: IRequestMessage) { - return this.doReviewMessage(message, (body) => this.submitReview(body)); - } - - private reRequestReview(message: IRequestMessage): void { - let targetReviewer: ReviewState | undefined; - const userReviewers: string[] = []; - const teamReviewers: string[] = []; - - for (const reviewer of this._existingReviewers) { - let id: string | undefined; - let reviewerArray: string[] | undefined; - if (reviewer && isTeam(reviewer.reviewer)) { - id = reviewer.reviewer.id; - reviewerArray = teamReviewers; - } else if (reviewer && !isTeam(reviewer.reviewer)) { - id = reviewer.reviewer.id; - reviewerArray = userReviewers; - } - if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { - reviewerArray.push(id); - if (id === message.args) { - targetReviewer = reviewer; - } - } - } - - this._item.requestReview(userReviewers, teamReviewers).then(() => { - if (targetReviewer) { - targetReviewer.state = 'REQUESTED'; - } - this._replyMessage(message, { - reviewers: this._existingReviewers, - }); - }); - } - - private async copyPrLink(): Promise { - return vscode.env.clipboard.writeText(this._item.html_url); - } - - private async copyVscodeDevLink(): Promise { - return vscode.env.clipboard.writeText(vscodeDevPrLink(this._item)); - } - - private async updateAutoMerge(message: IRequestMessage<{ autoMerge?: boolean, autoMergeMethod: MergeMethod }>): Promise { - let replyMessage: { autoMerge: boolean, autoMergeMethod?: MergeMethod }; - if (!message.args.autoMerge && !this._item.autoMerge) { - replyMessage = { autoMerge: false }; - } else if ((message.args.autoMerge === false) && this._item.autoMerge) { - await this._item.disableAutoMerge(); - replyMessage = { autoMerge: this._item.autoMerge }; - } else { - if (this._item.autoMerge && message.args.autoMergeMethod !== this._item.autoMergeMethod) { - await this._item.disableAutoMerge(); - } - await this._item.enableAutoMerge(message.args.autoMergeMethod); - replyMessage = { autoMerge: this._item.autoMerge, autoMergeMethod: this._item.autoMergeMethod }; - } - this._replyMessage(message, replyMessage); - } - - private async dequeue(message: IRequestMessage): Promise { - const result = await this._item.dequeuePullRequest(); - this._replyMessage(message, result); - } - - private async enqueue(message: IRequestMessage): Promise { - const result = await this._item.enqueuePullRequest(); - this._replyMessage(message, { mergeQueueEntry: result }); - } - - protected editCommentPromise(comment: IComment, text: string): Promise { - return this._item.editReviewComment(comment, text); - } - - protected deleteCommentPromise(comment: IComment): Promise { - return this._item.deleteReviewComment(comment.id.toString()); - } - - dispose() { - super.dispose(); - dispose(this._prListeners); - } -} - -export function getDefaultMergeMethod( - methodsAvailability: MergeMethodsAvailability, -): MergeMethod { - const userPreferred = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEFAULT_MERGE_METHOD); - // Use default merge method specified by user if it is available - if (userPreferred && methodsAvailability.hasOwnProperty(userPreferred) && methodsAvailability[userPreferred]) { - return userPreferred; - } - const methods: MergeMethod[] = ['merge', 'squash', 'rebase']; - // GitHub requires to have at least one merge method to be enabled; use first available as default - return methods.find(method => methodsAvailability[method])!; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; +import { IComment } from '../common/comment'; +import Logger from '../common/logger'; +import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; +import { asPromise, dispose, formatError } from '../common/utils'; +import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { + GithubItemStateEnum, + IAccount, + IMilestone, + IProject, + IProjectItem, + isTeam, + ITeam, + MergeMethod, + MergeMethodsAvailability, + reviewerId, + ReviewEvent, + ReviewState, +} from './interface'; +import { IssueOverviewPanel } from './issueOverview'; +import { PullRequestModel } from './pullRequestModel'; +import { PullRequestView } from './pullRequestOverviewCommon'; +import { getAssigneesQuickPickItems, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { isInCodespaces, parseReviewers, vscodeDevPrLink } from './utils'; +import { ProjectItemsReply, PullRequest, ReviewType } from './views'; + +export class PullRequestOverviewPanel extends IssueOverviewPanel { + public static ID: string = 'PullRequestOverviewPanel'; + /** + * Track the currently panel. Only allow a single panel to exist at a time. + */ + public static currentPanel?: PullRequestOverviewPanel; + + private _repositoryDefaultBranch: string; + private _existingReviewers: ReviewState[] = []; + private _teamsCount = 0; + + private _prListeners: vscode.Disposable[] = []; + private _isUpdating: boolean = false; + + public static async createOrShow( + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + issue: PullRequestModel, + toTheSide: boolean = false, + preserveFocus: boolean = true + ) { + const activeColumn = toTheSide + ? vscode.ViewColumn.Beside + : vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : vscode.ViewColumn.One; + + // If we already have a panel, show it. + // Otherwise, create a new panel. + if (PullRequestOverviewPanel.currentPanel) { + PullRequestOverviewPanel.currentPanel._panel.reveal(activeColumn, preserveFocus); + } else { + const title = `Pull Request #${issue.number.toString()}`; + PullRequestOverviewPanel.currentPanel = new PullRequestOverviewPanel( + extensionUri, + activeColumn || vscode.ViewColumn.Active, + title, + folderRepositoryManager, + ); + } + + await PullRequestOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); + } + + protected set _currentPanel(panel: PullRequestOverviewPanel | undefined) { + PullRequestOverviewPanel.currentPanel = panel; + } + + public static refresh(): void { + if (this.currentPanel) { + this.currentPanel.refreshPanel(); + } + } + + public static scrollToReview(): void { + if (this.currentPanel) { + this.currentPanel._postMessage({ command: 'pr.scrollToPendingReview' }); + } + } + + protected constructor( + extensionUri: vscode.Uri, + column: vscode.ViewColumn, + title: string, + folderRepositoryManager: FolderRepositoryManager, + ) { + super(extensionUri, column, title, folderRepositoryManager, PULL_REQUEST_OVERVIEW_VIEW_TYPE); + + this.registerPrListeners(); + onDidUpdatePR( + pr => { + if (pr) { + this._item.update(pr); + } + + this._postMessage({ + command: 'update-state', + state: this._item.state, + }); + }, + null, + this._disposables, + ); + + this._disposables.push( + folderRepositoryManager.onDidMergePullRequest(_ => { + this._postMessage({ + command: 'update-state', + state: GithubItemStateEnum.Merged, + }); + }), + ); + this._disposables.push(folderRepositoryManager.credentialStore.onDidUpgradeSession(() => { + this.updatePullRequest(this._item); + })); + + this._disposables.push(vscode.commands.registerCommand('review.approveDescription', (e) => this.approvePullRequestCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.commentDescription', (e) => this.submitReviewCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesDescription', (e) => this.requestChangesCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.approveOnDotComDescription', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + } + + registerPrListeners() { + dispose(this._prListeners); + this._prListeners = []; + this._prListeners.push(this._folderRepositoryManager.onDidChangeActivePullRequest(_ => { + if (this._folderRepositoryManager && this._item) { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); + this._postMessage({ + command: 'pr.update-checkout-status', + isCurrentlyCheckedOut, + }); + } + })); + + if (this._item) { + this._prListeners.push(this._item.onDidChangeComments(() => { + if (!this._isUpdating) { + this.refreshPanel(); + } + })); + } + } + + /** + * Find currently configured user's review status for the current PR + * @param reviewers All the reviewers who have been requested to review the current PR + * @param pullRequestModel Model of the PR + */ + private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { + const review = reviewers.find(r => reviewerId(r.reviewer) === currentUser.login); + // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user + return review?.state; + } + + private async updatePullRequest(pullRequestModel: PullRequestModel): Promise { + return Promise.all([ + this._folderRepositoryManager.resolvePullRequest( + pullRequestModel.remote.owner, + pullRequestModel.remote.repositoryName, + pullRequestModel.number, + ), + pullRequestModel.getTimelineEvents(), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), + pullRequestModel.getStatusChecks(), + pullRequestModel.getReviewRequests(), + this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), + this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), + this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), + pullRequestModel.canEdit(), + this._folderRepositoryManager.getOrgTeamsCount(pullRequestModel.githubRepository), + this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName)]) + .then(result => { + const [ + pullRequest, + timelineEvents, + defaultBranch, + status, + requestedReviewers, + repositoryAccess, + branchInfo, + currentUser, + viewerCanEdit, + orgTeamsCount, + mergeQueueMethod + ] = result; + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); + } + + this._item = pullRequest; + this.registerPrListeners(); + this._repositoryDefaultBranch = defaultBranch!; + this._teamsCount = orgTeamsCount; + this.setPanelTitle(`Pull Request #${pullRequestModel.number.toString()}`); + + const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); + const hasWritePermission = repositoryAccess!.hasWritePermission; + const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; + const canEdit = hasWritePermission || viewerCanEdit; + + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); + this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author); + + const isCrossRepository = + pullRequest.base && + !!pullRequest.head && + !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + + const continueOnGitHub = isCrossRepository && isInCodespaces(); + const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); + Logger.debug('pr.initialize', PullRequestOverviewPanel.ID); + const context: Partial = { + number: pullRequest.number, + title: pullRequest.title, + titleHTML: pullRequest.titleHTML, + url: pullRequest.html_url, + createdAt: pullRequest.createdAt, + body: pullRequest.body, + bodyHTML: pullRequest.bodyHTML, + labels: pullRequest.item.labels, + author: { + id: pullRequest.author.id, + login: pullRequest.author.login, + name: pullRequest.author.name, + avatarUrl: pullRequest.userAvatar, + url: pullRequest.author.url, + }, + state: pullRequest.state, + events: timelineEvents, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: pullRequest.base.label, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head?.label ?? '', + repositoryDefaultBranch: defaultBranch, + canEdit: canEdit, + hasWritePermission, + status: status[0], + reviewRequirement: status[1], + mergeable: pullRequest.item.mergeable, + reviewers: this._existingReviewers, + isDraft: pullRequest.isDraft, + mergeMethodsAvailability, + defaultMergeMethod, + autoMerge: pullRequest.autoMerge, + allowAutoMerge: pullRequest.allowAutoMerge, + autoMergeMethod: pullRequest.autoMergeMethod, + mergeQueueMethod: mergeQueueMethod, + mergeQueueEntry: pullRequest.mergeQueueEntry, + mergeCommitMeta: pullRequest.mergeCommitMeta, + squashCommitMeta: pullRequest.squashCommitMeta, + isIssue: false, + projectItems: pullRequest.item.projectItems, + milestone: pullRequest.milestone, + assignees: pullRequest.assignees, + continueOnGitHub, + isAuthor: currentUser.login === pullRequest.author.login, + currentUserReviewState: reviewState, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + isEnterprise: pullRequest.githubRepository.remote.isEnterprise + }; + this._postMessage({ + command: 'pr.initialize', + pullrequest: context + }); + if (pullRequest.isResolved()) { + this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); + } + }) + .catch(e => { + vscode.window.showErrorMessage(formatError(e)); + }); + } + + public async update( + folderRepositoryManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ): Promise { + if (this._folderRepositoryManager !== folderRepositoryManager) { + this._folderRepositoryManager = folderRepositoryManager; + this.registerPrListeners(); + } + + this._postMessage({ + command: 'set-scroll', + scrollPosition: this._scrollPosition, + }); + + if (!this._item || (this._item.number !== pullRequestModel.number) || !this._panel.webview.html) { + this._panel.webview.html = this.getHtmlForWebview(pullRequestModel.number.toString()); + } + + return this.updatePullRequest(pullRequestModel); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + switch (message.command) { + case 'pr.checkout': + return this.checkoutPullRequest(message); + case 'pr.merge': + return this.mergePullRequest(message); + case 'pr.deleteBranch': + return this.deleteBranch(message); + case 'pr.readyForReview': + return this.setReadyForReview(message); + case 'pr.approve': + return this.approvePullRequestMessage(message); + case 'pr.request-changes': + return this.requestChangesMessage(message); + case 'pr.submit': + return this.submitReviewMessage(message); + case 'pr.checkout-default-branch': + return this.checkoutDefaultBranch(message); + case 'pr.apply-patch': + return this.applyPatch(message); + case 'pr.open-diff': + return this.openDiff(message); + case 'pr.resolve-comment-thread': + return this.resolveCommentThread(message); + case 'pr.checkMergeability': + return this._replyMessage(message, await this._item.getMergeability()); + case 'pr.change-reviewers': + return this.changeReviewers(message); + case 'pr.remove-milestone': + return this.removeMilestone(message); + case 'pr.add-milestone': + return this.addMilestone(message); + case 'pr.change-projects': + return this.changeProjects(message); + case 'pr.remove-project': + return this.removeProject(message); + case 'pr.change-assignees': + return this.changeAssignees(message); + case 'pr.add-assignee-yourself': + return this.addAssigneeYourself(message); + case 'pr.copy-prlink': + return this.copyPrLink(); + case 'pr.copy-vscodedevlink': + return this.copyVscodeDevLink(); + case 'pr.openOnGitHub': + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + case 'pr.update-automerge': + return this.updateAutoMerge(message); + case 'pr.dequeue': + return this.dequeue(message); + case 'pr.enqueue': + return this.enqueue(message); + case 'pr.gotoChangesSinceReview': + this.gotoChangesSinceReview(); + break; + case 'pr.re-request-review': + this.reRequestReview(message); + break; + } + } + + private gotoChangesSinceReview() { + this._item.showChangesSinceReview = true; + } + + private async changeReviewers(message: IRequestMessage): Promise { + let quickPick: vscode.QuickPick | undefined; + + try { + quickPick = await reviewersQuickPick(this._folderRepositoryManager, this._item.remote.remoteName, this._item.base.isInOrganization, this._teamsCount, this._item.author, this._existingReviewers, this._item.suggestedReviewers); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + const newUserReviewers: string[] = []; + const newTeamReviewers: string[] = []; + allReviewers.forEach(reviewer => { + const newReviewers = isTeam(reviewer.user) ? newTeamReviewers : newUserReviewers; + newReviewers.push(reviewer.user.id); + }); + + const removedUserReviewers: string[] = []; + const removedTeamReviewers: string[] = []; + this._existingReviewers.forEach(existing => { + let newReviewers: string[] = isTeam(existing.reviewer) ? newTeamReviewers : newUserReviewers; + let removedReviewers: string[] = isTeam(existing.reviewer) ? removedTeamReviewers : removedUserReviewers; + if (!newReviewers.find(newTeamReviewer => newTeamReviewer === existing.reviewer.id)) { + removedReviewers.push(existing.reviewer.id); + } + }); + + await this._item.requestReview(newUserReviewers, newTeamReviewers); + await this._item.deleteReviewRequest(removedUserReviewers, removedTeamReviewers); + const addedReviewers: ReviewState[] = allReviewers.map(selected => { + return { + reviewer: selected.user, + state: 'REQUESTED', + }; + }); + + this._existingReviewers = addedReviewers; + await this._replyMessage(message, { + reviewers: addedReviewers, + }); + } + } catch (e) { + Logger.error(formatError(e)); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); + } + } + + private async addMilestone(message: IRequestMessage): Promise { + return getMilestoneFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.milestone, (milestone) => this.updateMilestone(milestone, message)); + } + + private async updateMilestone(milestone: IMilestone | undefined, message: IRequestMessage) { + if (!milestone) { + return this.removeMilestone(message); + } + await this._item.updateMilestone(milestone.id); + this._replyMessage(message, { + added: milestone, + }); + } + + private async removeMilestone(message: IRequestMessage): Promise { + try { + await this._item.updateMilestone('null'); + this._replyMessage(message, {}); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private async changeProjects(message: IRequestMessage): Promise { + return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.remote.remoteName, this._item.base.isInOrganization, this._item.item.projectItems, (project) => this.updateProjects(project, message)); + } + + private async updateProjects(projects: IProject[] | undefined, message: IRequestMessage) { + if (projects) { + const newProjects = await this._item.updateProjects(projects); + const projectItemsReply: ProjectItemsReply = { + projectItems: newProjects, + }; + return this._replyMessage(message, projectItemsReply); + } + } + + private async removeProject(message: IRequestMessage): Promise { + await this._item.removeProjects([message.args]); + return this._replyMessage(message, {}); + } + + private async changeAssignees(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); + + try { + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.show(); + quickPick.items = await getAssigneesQuickPickItems(this._folderRepositoryManager, this._item.remote.remoteName, this._item.assignees ?? [], this._item); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allAssignees = await Promise.race<(vscode.QuickPickItem & { user: IAccount })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allAssignees) { + const newAssignees: IAccount[] = allAssignees.map(item => item.user); + const removeAssignees: IAccount[] = this._item.assignees?.filter(currentAssignee => !newAssignees.find(newAssignee => newAssignee.login === currentAssignee.login)) ?? []; + this._item.assignees = newAssignees; + + await this._item.addAssignees(newAssignees.map(assignee => assignee.login)); + await this._item.deleteAssignees(removeAssignees.map(assignee => assignee.login)); + await this._replyMessage(message, { + assignees: newAssignees, + }); + } + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); + } + } + + private async addAssigneeYourself(message: IRequestMessage): Promise { + try { + const currentUser = await this._folderRepositoryManager.getCurrentUser(); + const alreadyAssigned = this._item.assignees?.find(user => user.login === currentUser.login); + if (!alreadyAssigned) { + this._item.assignees = this._item.assignees?.concat(currentUser); + await this._item.addAssignees([currentUser.login]); + } + this._replyMessage(message, { + assignees: this._item.assignees, + }); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private async applyPatch(message: IRequestMessage<{ comment: IComment }>): Promise { + try { + const comment = message.args.comment; + const regex = /```diff\n([\s\S]*)\n```/g; + const matches = regex.exec(comment.body); + + const tempUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, '.git', `${comment.id}.diff`); + + const encoder = new TextEncoder(); + + await vscode.workspace.fs.writeFile(tempUri, encoder.encode(matches![1])); + await this._folderRepositoryManager.repository.apply(tempUri.fsPath); + await vscode.workspace.fs.delete(tempUri); + vscode.window.showInformationMessage('Patch applied!'); + } catch (e) { + Logger.error(`Applying patch failed: ${e}`, PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); + } + } + + private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { + try { + const comment = message.args.comment; + return PullRequestModel.openDiffFromComment(this._folderRepositoryManager, this._item, comment); + } catch (e) { + Logger.error(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + } + } + + private async resolveCommentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) { + try { + if (message.args.toResolve) { + await this._item.resolveReviewThread(message.args.threadId); + } + else { + await this._item.unresolveReviewThread(message.args.threadId); + } + const timelineEvents = await this._item.getTimelineEvents(); + this._replyMessage(message, timelineEvents); + } catch (e) { + vscode.window.showErrorMessage(e); + this._replyMessage(message, undefined); + } + } + + private checkoutPullRequest(message: IRequestMessage): void { + vscode.commands.executeCommand('pr.pick', this._item).then( + () => { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); + this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); + }, + () => { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); + this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); + }, + ); + } + + private mergePullRequest( + message: IRequestMessage<{ title: string | undefined; description: string | undefined; method: 'merge' | 'squash' | 'rebase' }>, + ): void { + const { title, description, method } = message.args; + this._folderRepositoryManager + .mergePullRequest(this._item, title, description, method) + .then(result => { + vscode.commands.executeCommand('pr.refreshList'); + + if (!result.merged) { + vscode.window.showErrorMessage(`Merging PR failed: ${result.message}`); + } + + this._replyMessage(message, { + state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, + }); + }) + .catch(e => { + vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); + this._throwError(message, {}); + }); + } + + private async deleteBranch(message: IRequestMessage) { + const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + if (result.isReply) { + this._replyMessage(message, result.message); + } else { + this.refreshPanel(); + this._postMessage(result.message); + } + } + + private setReadyForReview(message: IRequestMessage<{}>): void { + this._item + .setReadyForReview() + .then(isDraft => { + vscode.commands.executeCommand('pr.refreshList'); + + this._replyMessage(message, { isDraft }); + }) + .catch(e => { + vscode.window.showErrorMessage(`Unable to set PR ready for review. ${formatError(e)}`); + this._throwError(message, {}); + }); + } + + private async checkoutDefaultBranch(message: IRequestMessage): Promise { + try { + const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; + await this._folderRepositoryManager.checkoutDefaultBranch(message.args); + if (prBranch) { + await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); + } + } finally { + // Complete webview promise so that button becomes enabled again + this._replyMessage(message, {}); + } + } + + private updateReviewers(review?: CommonReviewEvent): void { + if (review) { + const existingReviewer = this._existingReviewers.find( + reviewer => review.user.login === (reviewer.reviewer as IAccount).login, + ); + if (existingReviewer) { + existingReviewer.state = review.state; + } else { + this._existingReviewers.push({ + reviewer: review.user, + state: review.state, + }); + } + } + } + + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { + const submittingMessage = { + command: 'pr.submitting-review', + lastReviewType: reviewType + }; + this._postMessage(submittingMessage); + try { + const review = await action(context.body); + this.updateReviewers(review); + const reviewMessage = { + command: 'pr.append-review', + review, + reviewers: this._existingReviewers + }; + await this._postMessage(reviewMessage); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(undefined, `${formatError(e)}`); + } finally { + this._postMessage({ command: 'pr.append-review' }); + } + } + + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + try { + const review = await action(message.args); + this.updateReviewers(review); + this._replyMessage(message, { + review: review, + reviewers: this._existingReviewers, + }); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } + } + + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); + } + + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + } + + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } + + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEvent.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); + } + + private reRequestReview(message: IRequestMessage): void { + let targetReviewer: ReviewState | undefined; + const userReviewers: string[] = []; + const teamReviewers: string[] = []; + + for (const reviewer of this._existingReviewers) { + let id: string | undefined; + let reviewerArray: string[] | undefined; + if (reviewer && isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = teamReviewers; + } else if (reviewer && !isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = userReviewers; + } + if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { + reviewerArray.push(id); + if (id === message.args) { + targetReviewer = reviewer; + } + } + } + + this._item.requestReview(userReviewers, teamReviewers).then(() => { + if (targetReviewer) { + targetReviewer.state = 'REQUESTED'; + } + this._replyMessage(message, { + reviewers: this._existingReviewers, + }); + }); + } + + private async copyPrLink(): Promise { + return vscode.env.clipboard.writeText(this._item.html_url); + } + + private async copyVscodeDevLink(): Promise { + return vscode.env.clipboard.writeText(vscodeDevPrLink(this._item)); + } + + private async updateAutoMerge(message: IRequestMessage<{ autoMerge?: boolean, autoMergeMethod: MergeMethod }>): Promise { + let replyMessage: { autoMerge: boolean, autoMergeMethod?: MergeMethod }; + if (!message.args.autoMerge && !this._item.autoMerge) { + replyMessage = { autoMerge: false }; + } else if ((message.args.autoMerge === false) && this._item.autoMerge) { + await this._item.disableAutoMerge(); + replyMessage = { autoMerge: this._item.autoMerge }; + } else { + if (this._item.autoMerge && message.args.autoMergeMethod !== this._item.autoMergeMethod) { + await this._item.disableAutoMerge(); + } + await this._item.enableAutoMerge(message.args.autoMergeMethod); + replyMessage = { autoMerge: this._item.autoMerge, autoMergeMethod: this._item.autoMergeMethod }; + } + this._replyMessage(message, replyMessage); + } + + private async dequeue(message: IRequestMessage): Promise { + const result = await this._item.dequeuePullRequest(); + this._replyMessage(message, result); + } + + private async enqueue(message: IRequestMessage): Promise { + const result = await this._item.enqueuePullRequest(); + this._replyMessage(message, { mergeQueueEntry: result }); + } + + protected editCommentPromise(comment: IComment, text: string): Promise { + return this._item.editReviewComment(comment, text); + } + + protected deleteCommentPromise(comment: IComment): Promise { + return this._item.deleteReviewComment(comment.id.toString()); + } + + dispose() { + super.dispose(); + dispose(this._prListeners); + } +} + +export function getDefaultMergeMethod( + methodsAvailability: MergeMethodsAvailability, +): MergeMethod { + const userPreferred = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEFAULT_MERGE_METHOD); + // Use default merge method specified by user if it is available + if (userPreferred && methodsAvailability.hasOwnProperty(userPreferred) && methodsAvailability[userPreferred]) { + return userPreferred; + } + const methods: MergeMethod[] = ['merge', 'squash', 'rebase']; + // GitHub requires to have at least one merge method to be enabled; use first available as default + return methods.find(method => methodsAvailability[method])!; +} diff --git a/src/github/pullRequestOverviewCommon.ts b/src/github/pullRequestOverviewCommon.ts index cbc9867a61..a1cc5ba1da 100644 --- a/src/github/pullRequestOverviewCommon.ts +++ b/src/github/pullRequestOverviewCommon.ts @@ -1,149 +1,150 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import { - DEFAULT_DELETION_METHOD, - PR_SETTINGS_NAMESPACE, - SELECT_LOCAL_BRANCH, - SELECT_REMOTE, -} from '../common/settingKeys'; -import { Schemes } from '../common/uri'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { PullRequestModel } from './pullRequestModel'; - -export namespace PullRequestView { - export async function deleteBranch(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise<{ isReply: boolean, message: any }> { - const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); - const actions: (vscode.QuickPickItem & { type: 'upstream' | 'local' | 'remote' | 'suspend' })[] = []; - const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); - - if (item.isResolved()) { - const branchHeadRef = item.head.ref; - - const isDefaultBranch = defaultBranch === item.head.ref; - if (!isDefaultBranch && !item.isRemoteHeadDeleted) { - actions.push({ - label: vscode.l10n.t('Delete remote branch {0}', `${item.remote.remoteName}/${branchHeadRef}`), - description: `${item.remote.normalizedHost}/${item.remote.owner}/${item.remote.repositoryName}`, - type: 'upstream', - picked: true, - }); - } - } - - if (branchInfo) { - const preferredLocalBranchDeletionMethod = vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`); - actions.push({ - label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), - type: 'local', - picked: !!preferredLocalBranchDeletionMethod, - }); - - const preferredRemoteDeletionMethod = vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`); - - if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { - actions.push({ - label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), - type: 'remote', - picked: !!preferredRemoteDeletionMethod, - }); - } - } - - if (vscode.env.remoteName === 'codespaces') { - actions.push({ - label: vscode.l10n.t('Suspend Codespace'), - type: 'suspend' - }); - } - - if (!actions.length) { - vscode.window.showWarningMessage( - vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), - ); - return { - isReply: true, - message: { - cancelled: true - } - }; - } - - const selectedActions = await vscode.window.showQuickPick(actions, { - canPickMany: true, - ignoreFocusOut: true, - }); - - const deletedBranchTypes: string[] = []; - - if (selectedActions) { - const isBranchActive = item.equals(folderRepositoryManager.activePullRequest); - - const promises = selectedActions.map(async action => { - switch (action.type) { - case 'upstream': - await folderRepositoryManager.deleteBranch(item); - deletedBranchTypes.push(action.type); - await folderRepositoryManager.repository.fetch({ prune: true }); - // If we're in a remote repository, then we should checkout the default branch. - if (folderRepositoryManager.repository.rootUri.scheme === Schemes.VscodeVfs) { - await folderRepositoryManager.repository.checkout(defaultBranch); - } - return; - case 'local': - if (isBranchActive) { - if (folderRepositoryManager.repository.state.workingTreeChanges.length) { - const yes = vscode.l10n.t('Yes'); - const response = await vscode.window.showWarningMessage( - vscode.l10n.t('Your local changes will be lost, do you want to continue?'), - { modal: true }, - yes, - ); - if (response === yes) { - await vscode.commands.executeCommand('git.cleanAll'); - } else { - return; - } - } - await folderRepositoryManager.repository.checkout(defaultBranch); - } - await folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); - return deletedBranchTypes.push(action.type); - case 'remote': - deletedBranchTypes.push(action.type); - return folderRepositoryManager.repository.removeRemote(branchInfo!.remote!); - case 'suspend': - deletedBranchTypes.push(action.type); - return vscode.commands.executeCommand('github.codespaces.disconnectSuspend'); - } - }); - - await Promise.all(promises); - - vscode.commands.executeCommand('pr.refreshList'); - - return { - isReply: false, - message: { - command: 'pr.deleteBranch', - branchTypes: deletedBranchTypes - } - }; - } else { - return { - isReply: true, - message: { - cancelled: true - } - }; - } - } -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import { + DEFAULT_DELETION_METHOD, + PR_SETTINGS_NAMESPACE, + SELECT_LOCAL_BRANCH, + SELECT_REMOTE, +} from '../common/settingKeys'; +import { Schemes } from '../common/uri'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { PullRequestModel } from './pullRequestModel'; + +export namespace PullRequestView { + export async function deleteBranch(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise<{ isReply: boolean, message: any }> { + const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); + const actions: (vscode.QuickPickItem & { type: 'upstream' | 'local' | 'remote' | 'suspend' })[] = []; + const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); + + if (item.isResolved()) { + const branchHeadRef = item.head.ref; + + const isDefaultBranch = defaultBranch === item.head.ref; + if (!isDefaultBranch && !item.isRemoteHeadDeleted) { + actions.push({ + label: vscode.l10n.t('Delete remote branch {0}', `${item.remote.remoteName}/${branchHeadRef}`), + description: `${item.remote.normalizedHost}/${item.remote.owner}/${item.remote.repositoryName}`, + type: 'upstream', + picked: true, + }); + } + } + + if (branchInfo) { + const preferredLocalBranchDeletionMethod = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`); + actions.push({ + label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), + type: 'local', + picked: !!preferredLocalBranchDeletionMethod, + }); + + const preferredRemoteDeletionMethod = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`); + + if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { + actions.push({ + label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), + type: 'remote', + picked: !!preferredRemoteDeletionMethod, + }); + } + } + + if (vscode.env.remoteName === 'codespaces') { + actions.push({ + label: vscode.l10n.t('Suspend Codespace'), + type: 'suspend' + }); + } + + if (!actions.length) { + vscode.window.showWarningMessage( + vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), + ); + return { + isReply: true, + message: { + cancelled: true + } + }; + } + + const selectedActions = await vscode.window.showQuickPick(actions, { + canPickMany: true, + ignoreFocusOut: true, + }); + + const deletedBranchTypes: string[] = []; + + if (selectedActions) { + const isBranchActive = item.equals(folderRepositoryManager.activePullRequest); + + const promises = selectedActions.map(async action => { + switch (action.type) { + case 'upstream': + await folderRepositoryManager.deleteBranch(item); + deletedBranchTypes.push(action.type); + await folderRepositoryManager.repository.fetch({ prune: true }); + // If we're in a remote repository, then we should checkout the default branch. + if (folderRepositoryManager.repository.rootUri.scheme === Schemes.VscodeVfs) { + await folderRepositoryManager.repository.checkout(defaultBranch); + } + return; + case 'local': + if (isBranchActive) { + if (folderRepositoryManager.repository.state.workingTreeChanges.length) { + const yes = vscode.l10n.t('Yes'); + const response = await vscode.window.showWarningMessage( + vscode.l10n.t('Your local changes will be lost, do you want to continue?'), + { modal: true }, + yes, + ); + if (response === yes) { + await vscode.commands.executeCommand('git.cleanAll'); + } else { + return; + } + } + await folderRepositoryManager.repository.checkout(defaultBranch); + } + await folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); + return deletedBranchTypes.push(action.type); + case 'remote': + deletedBranchTypes.push(action.type); + return folderRepositoryManager.repository.removeRemote(branchInfo!.remote!); + case 'suspend': + deletedBranchTypes.push(action.type); + return vscode.commands.executeCommand('github.codespaces.disconnectSuspend'); + } + }); + + await Promise.all(promises); + + vscode.commands.executeCommand('pr.refreshList'); + + return { + isReply: false, + message: { + command: 'pr.deleteBranch', + branchTypes: deletedBranchTypes + } + }; + } else { + return { + isReply: true, + message: { + cancelled: true + } + }; + } + } +} diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts index 056532b228..e3ded08403 100644 --- a/src/github/quickPicks.ts +++ b/src/github/quickPicks.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; + import { Buffer } from 'buffer'; import * as vscode from 'vscode'; import { RemoteInfo } from '../../common/views'; @@ -386,4 +387,4 @@ export async function getLabelOptions( }; }); return { newLabels, labelPicks }; -} \ No newline at end of file +} diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index 6fb5065679..ae0a8a6e8f 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -1,256 +1,257 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { AuthProvider } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import Logger from '../common/logger'; -import { ITelemetry } from '../common/telemetry'; -import { EventType } from '../common/timelineEvent'; -import { compareIgnoreCase, dispose } from '../common/utils'; -import { CredentialStore } from './credentials'; -import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; -import { IssueModel } from './issueModel'; -import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; - -export interface ItemsResponseResult { - items: T[]; - hasMorePages: boolean; - hasUnsearchedRepositories: boolean; -} - -export interface PullRequestDefaults { - owner: string; - repo: string; - base: string; -} - -export class RepositoriesManager implements vscode.Disposable { - static ID = 'RepositoriesManager'; - - private _folderManagers: FolderRepositoryManager[] = []; - private _subs: Map; - - private _onDidChangeState = new vscode.EventEmitter(); - readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; - - private _onDidChangeFolderRepositories = new vscode.EventEmitter<{ added?: FolderRepositoryManager }>(); - readonly onDidChangeFolderRepositories = this._onDidChangeFolderRepositories.event; - - private _onDidLoadAnyRepositories = new vscode.EventEmitter(); - readonly onDidLoadAnyRepositories = this._onDidLoadAnyRepositories.event; - - private _state: ReposManagerState = ReposManagerState.Initializing; - - constructor( - private _credentialStore: CredentialStore, - private _telemetry: ITelemetry, - ) { - this._subs = new Map(); - vscode.commands.executeCommand('setContext', ReposManagerStateContext, this._state); - } - - private updateActiveReviewCount() { - let count = 0; - for (const folderManager of this._folderManagers) { - if (folderManager.activePullRequest) { - count++; - } - } - commands.setContext(contexts.ACTIVE_PR_COUNT, count); - } - - get folderManagers(): FolderRepositoryManager[] { - return this._folderManagers; - } - - private registerFolderListeners(folderManager: FolderRepositoryManager) { - const disposables = [ - folderManager.onDidLoadRepositories(state => { - this.state = state; - this._onDidLoadAnyRepositories.fire(); - }), - folderManager.onDidChangeActivePullRequest(() => this.updateActiveReviewCount()), - folderManager.onDidDispose(() => this.removeRepo(folderManager.repository)) - ]; - this._subs.set(folderManager, disposables); - } - - insertFolderManager(folderManager: FolderRepositoryManager) { - this.registerFolderListeners(folderManager); - - // Try to insert the new repository in workspace folder order - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders) { - const index = workspaceFolders.findIndex( - folder => folder.uri.toString() === folderManager.repository.rootUri.toString(), - ); - if (index > -1) { - const arrayEnd = this._folderManagers.slice(index, this._folderManagers.length); - this._folderManagers = this._folderManagers.slice(0, index); - this._folderManagers.push(folderManager); - this._folderManagers.push(...arrayEnd); - this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire({ added: folderManager }); - return; - } - } - this._folderManagers.push(folderManager); - this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire({ added: folderManager }); - } - - removeRepo(repo: Repository) { - const existingFolderManagerIndex = this._folderManagers.findIndex( - manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), - ); - if (existingFolderManagerIndex > -1) { - const folderManager = this._folderManagers[existingFolderManagerIndex]; - dispose(this._subs.get(folderManager)!); - this._subs.delete(folderManager); - this._folderManagers.splice(existingFolderManagerIndex); - folderManager.dispose(); - this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire({}); - } - } - - getManagerForIssueModel(issueModel: IssueModel | undefined): FolderRepositoryManager | undefined { - if (issueModel === undefined) { - return undefined; - } - const issueRemoteUrl = `${issueModel.remote.owner.toLowerCase()}/${issueModel.remote.repositoryName.toLowerCase()}`; - for (const folderManager of this._folderManagers) { - if ( - folderManager.gitHubRepositories - .map(repo => - `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` - ) - .includes(issueRemoteUrl) - ) { - return folderManager; - } - } - return undefined; - } - - getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { - if (uri.scheme === 'untitled') { - return this._folderManagers[0]; - } - - // Prioritize longest path first to handle nested workspaces - const folderManagers = this._folderManagers - .slice() - .sort((a, b) => b.repository.rootUri.path.length - a.repository.rootUri.path.length); - - for (const folderManager of folderManagers) { - const managerPath = folderManager.repository.rootUri.path; - const testUriRelativePath = uri.path.substring( - managerPath.length > 1 ? managerPath.length + 1 : managerPath.length, - ); - if (compareIgnoreCase(vscode.Uri.joinPath(folderManager.repository.rootUri, testUriRelativePath).path, uri.path) === 0) { - return folderManager; - } - } - return undefined; - } - - get state() { - return this._state; - } - - set state(state: ReposManagerState) { - const stateChange = state !== this._state; - this._state = state; - if (stateChange) { - vscode.commands.executeCommand('setContext', ReposManagerStateContext, state); - this._onDidChangeState.fire(); - } - } - - get credentialStore(): CredentialStore { - return this._credentialStore; - } - - async clearCredentialCache(): Promise { - await this._credentialStore.reset(); - this.state = ReposManagerState.Initializing; - } - - async authenticate(enterprise?: boolean): Promise { - if (enterprise === false) { - return !!this._credentialStore.login(AuthProvider.github); - } - const { dotComRemotes, enterpriseRemotes, unknownRemotes } = await findDotComAndEnterpriseRemotes(this.folderManagers); - const yes = vscode.l10n.t('Yes'); - - if (enterprise) { - const remoteToUse = getEnterpriseUri()?.toString() ?? (enterpriseRemotes.length ? enterpriseRemotes[0].normalizedHost : (unknownRemotes.length ? unknownRemotes[0].normalizedHost : undefined)); - if (enterpriseRemotes.length === 0 && unknownRemotes.length === 0) { - Logger.appendLine(`Enterprise login selected, but no possible enterprise remotes discovered (${dotComRemotes.length} .com)`); - } - if (remoteToUse) { - const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse), - { modal: true }, yes, vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri')); - if (promptResult === yes) { - await setEnterpriseUri(remoteToUse); - } else { - return false; - } - } else { - const setEnterpriseUriPrompt = await vscode.window.showInputBox({ placeHolder: vscode.l10n.t('Set a GitHub Enterprise server URL'), ignoreFocusOut: true }); - if (setEnterpriseUriPrompt) { - await setEnterpriseUri(setEnterpriseUriPrompt); - } else { - return false; - } - } - } - // If we have no github.com remotes, but we do have github remotes, then we likely have github enterprise remotes. - else if (!hasEnterpriseUri() && (dotComRemotes.length === 0) && (enterpriseRemotes.length > 0)) { - const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('It looks like you might be using GitHub Enterprise. Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', enterpriseRemotes[0].normalizedHost), - { modal: true }, yes, vscode.l10n.t('No, use GitHub.com')); - if (promptResult === yes) { - await setEnterpriseUri(enterpriseRemotes[0].normalizedHost); - } else if (promptResult === undefined) { - return false; - } - } - - let githubEnterprise; - const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); - if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { - githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); - } - let github; - if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { - github = await this._credentialStore.login(AuthProvider.github); - } - return !!github || !!githubEnterprise; - } - - dispose() { - this._subs.forEach(sub => dispose(sub)); - } -} - -export function getEventType(text: string) { - switch (text) { - case 'committed': - return EventType.Committed; - case 'mentioned': - return EventType.Mentioned; - case 'subscribed': - return EventType.Subscribed; - case 'commented': - return EventType.Commented; - case 'reviewed': - return EventType.Reviewed; - default: - return EventType.Other; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { AuthProvider } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { EventType } from '../common/timelineEvent'; +import { compareIgnoreCase, dispose } from '../common/utils'; +import { CredentialStore } from './credentials'; +import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; +import { IssueModel } from './issueModel'; +import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; + +export interface ItemsResponseResult { + items: T[]; + hasMorePages: boolean; + hasUnsearchedRepositories: boolean; +} + +export interface PullRequestDefaults { + owner: string; + repo: string; + base: string; +} + +export class RepositoriesManager implements vscode.Disposable { + static ID = 'RepositoriesManager'; + + private _folderManagers: FolderRepositoryManager[] = []; + private _subs: Map; + + private _onDidChangeState = new vscode.EventEmitter(); + readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + + private _onDidChangeFolderRepositories = new vscode.EventEmitter<{ added?: FolderRepositoryManager }>(); + readonly onDidChangeFolderRepositories = this._onDidChangeFolderRepositories.event; + + private _onDidLoadAnyRepositories = new vscode.EventEmitter(); + readonly onDidLoadAnyRepositories = this._onDidLoadAnyRepositories.event; + + private _state: ReposManagerState = ReposManagerState.Initializing; + + constructor( + private _credentialStore: CredentialStore, + private _telemetry: ITelemetry, + ) { + this._subs = new Map(); + vscode.commands.executeCommand('setContext', ReposManagerStateContext, this._state); + } + + private updateActiveReviewCount() { + let count = 0; + for (const folderManager of this._folderManagers) { + if (folderManager.activePullRequest) { + count++; + } + } + commands.setContext(contexts.ACTIVE_PR_COUNT, count); + } + + get folderManagers(): FolderRepositoryManager[] { + return this._folderManagers; + } + + private registerFolderListeners(folderManager: FolderRepositoryManager) { + const disposables = [ + folderManager.onDidLoadRepositories(state => { + this.state = state; + this._onDidLoadAnyRepositories.fire(); + }), + folderManager.onDidChangeActivePullRequest(() => this.updateActiveReviewCount()), + folderManager.onDidDispose(() => this.removeRepo(folderManager.repository)) + ]; + this._subs.set(folderManager, disposables); + } + + insertFolderManager(folderManager: FolderRepositoryManager) { + this.registerFolderListeners(folderManager); + + // Try to insert the new repository in workspace folder order + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const index = workspaceFolders.findIndex( + folder => folder.uri.toString() === folderManager.repository.rootUri.toString(), + ); + if (index > -1) { + const arrayEnd = this._folderManagers.slice(index, this._folderManagers.length); + this._folderManagers = this._folderManagers.slice(0, index); + this._folderManagers.push(folderManager); + this._folderManagers.push(...arrayEnd); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); + return; + } + } + this._folderManagers.push(folderManager); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); + } + + removeRepo(repo: Repository) { + const existingFolderManagerIndex = this._folderManagers.findIndex( + manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), + ); + if (existingFolderManagerIndex > -1) { + const folderManager = this._folderManagers[existingFolderManagerIndex]; + dispose(this._subs.get(folderManager)!); + this._subs.delete(folderManager); + this._folderManagers.splice(existingFolderManagerIndex); + folderManager.dispose(); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({}); + } + } + + getManagerForIssueModel(issueModel: IssueModel | undefined): FolderRepositoryManager | undefined { + if (issueModel === undefined) { + return undefined; + } + const issueRemoteUrl = `${issueModel.remote.owner.toLowerCase()}/${issueModel.remote.repositoryName.toLowerCase()}`; + for (const folderManager of this._folderManagers) { + if ( + folderManager.gitHubRepositories + .map(repo => + `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` + ) + .includes(issueRemoteUrl) + ) { + return folderManager; + } + } + return undefined; + } + + getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { + if (uri.scheme === 'untitled') { + return this._folderManagers[0]; + } + + // Prioritize longest path first to handle nested workspaces + const folderManagers = this._folderManagers + .slice() + .sort((a, b) => b.repository.rootUri.path.length - a.repository.rootUri.path.length); + + for (const folderManager of folderManagers) { + const managerPath = folderManager.repository.rootUri.path; + const testUriRelativePath = uri.path.substring( + managerPath.length > 1 ? managerPath.length + 1 : managerPath.length, + ); + if (compareIgnoreCase(vscode.Uri.joinPath(folderManager.repository.rootUri, testUriRelativePath).path, uri.path) === 0) { + return folderManager; + } + } + return undefined; + } + + get state() { + return this._state; + } + + set state(state: ReposManagerState) { + const stateChange = state !== this._state; + this._state = state; + if (stateChange) { + vscode.commands.executeCommand('setContext', ReposManagerStateContext, state); + this._onDidChangeState.fire(); + } + } + + get credentialStore(): CredentialStore { + return this._credentialStore; + } + + async clearCredentialCache(): Promise { + await this._credentialStore.reset(); + this.state = ReposManagerState.Initializing; + } + + async authenticate(enterprise?: boolean): Promise { + if (enterprise === false) { + return !!this._credentialStore.login(AuthProvider.github); + } + const { dotComRemotes, enterpriseRemotes, unknownRemotes } = await findDotComAndEnterpriseRemotes(this.folderManagers); + const yes = vscode.l10n.t('Yes'); + + if (enterprise) { + const remoteToUse = getEnterpriseUri()?.toString() ?? (enterpriseRemotes.length ? enterpriseRemotes[0].normalizedHost : (unknownRemotes.length ? unknownRemotes[0].normalizedHost : undefined)); + if (enterpriseRemotes.length === 0 && unknownRemotes.length === 0) { + Logger.appendLine(`Enterprise login selected, but no possible enterprise remotes discovered (${dotComRemotes.length} .com)`); + } + if (remoteToUse) { + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse), + { modal: true }, yes, vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri')); + if (promptResult === yes) { + await setEnterpriseUri(remoteToUse); + } else { + return false; + } + } else { + const setEnterpriseUriPrompt = await vscode.window.showInputBox({ placeHolder: vscode.l10n.t('Set a GitHub Enterprise server URL'), ignoreFocusOut: true }); + if (setEnterpriseUriPrompt) { + await setEnterpriseUri(setEnterpriseUriPrompt); + } else { + return false; + } + } + } + // If we have no github.com remotes, but we do have github remotes, then we likely have github enterprise remotes. + else if (!hasEnterpriseUri() && (dotComRemotes.length === 0) && (enterpriseRemotes.length > 0)) { + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('It looks like you might be using GitHub Enterprise. Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', enterpriseRemotes[0].normalizedHost), + { modal: true }, yes, vscode.l10n.t('No, use GitHub.com')); + if (promptResult === yes) { + await setEnterpriseUri(enterpriseRemotes[0].normalizedHost); + } else if (promptResult === undefined) { + return false; + } + } + + let githubEnterprise; + const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); + if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { + githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); + } + let github; + if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { + github = await this._credentialStore.login(AuthProvider.github); + } + return !!github || !!githubEnterprise; + } + + dispose() { + this._subs.forEach(sub => dispose(sub)); + } +} + +export function getEventType(text: string) { + switch (text) { + case 'committed': + return EventType.Committed; + case 'mentioned': + return EventType.Mentioned; + case 'subscribed': + return EventType.Subscribed; + case 'commented': + return EventType.Commented; + case 'reviewed': + return EventType.Reviewed; + default: + return EventType.Other; + } +} diff --git a/src/github/utils.ts b/src/github/utils.ts index ced796c928..a6770f54d0 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -1,1377 +1,1378 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as crypto from 'crypto'; -import * as OctokitTypes from '@octokit/types'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { AuthProvider, GitHubServerType } from '../common/authentication'; -import { IComment, IReviewThread, Reaction, SubjectType } from '../common/comment'; -import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; -import { GitHubRef } from '../common/githubRef'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { Resource } from '../common/resources'; -import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; -import * as Common from '../common/timelineEvent'; -import { uniqBy } from '../common/utils'; -import { OctokitCommon } from './common'; -import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; -import { GitHubRepository, ViewerPermission } from './githubRepository'; -import * as GraphQL from './graphql'; -import { - IAccount, - IActor, - IGitHubRef, - ILabel, - IMilestone, - IProjectItem, - Issue, - ISuggestedReviewer, - ITeam, - MergeMethod, - MergeQueueEntry, - MergeQueueState, - PullRequest, - PullRequestMergeability, - reviewerId, - reviewerLabel, - ReviewState, - User, -} from './interface'; -import { IssueModel } from './issueModel'; -import { GHPRComment, GHPRCommentThread } from './prComment'; -import { PullRequestModel } from './pullRequestModel'; - -export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; -export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; - -export interface CommentReactionHandler { - toggleReaction(comment: vscode.Comment, reaction: vscode.CommentReaction): Promise; -} - -export type ParsedIssue = { - owner: string | undefined; - name: string | undefined; - issueNumber: number; - commentNumber?: number; -}; - -export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { - if (!output) { - return undefined; - } - const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; - if (output.length === 7) { - issue.owner = output[2]; - issue.name = output[3]; - issue.issueNumber = parseInt(output[5]); - return issue; - } else if (output.length === 16) { - issue.owner = output[3] || output[11]; - issue.name = output[4] || output[12]; - issue.issueNumber = parseInt(output[7] || output[14]); - issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; - return issue; - } else { - return undefined; - } -} - -export function threadRange(startLine: number, endLine: number, endCharacter?: number): vscode.Range { - if ((startLine !== endLine) && (endCharacter === undefined)) { - endCharacter = 300; // 300 is a "large" number that will select a lot of the line since don't know anything about the line length - } else if (!endCharacter) { - endCharacter = 0; - } - return new vscode.Range(startLine, 0, endLine, endCharacter); -} - -export function createVSCodeCommentThreadForReviewThread( - context: vscode.ExtensionContext, - uri: vscode.Uri, - range: vscode.Range | undefined, - thread: IReviewThread, - commentController: vscode.CommentController, - currentUser: string, - githubRepository?: GitHubRepository -): GHPRCommentThread { - const vscodeThread = commentController.createCommentThread(uri, range, []); - - (vscodeThread as GHPRCommentThread).gitHubThreadId = thread.id; - - vscodeThread.comments = thread.comments.map(comment => new GHPRComment(context, comment, vscodeThread as GHPRCommentThread, githubRepository)); - vscodeThread.state = isResolvedToResolvedState(thread.isResolved); - - if (thread.viewerCanResolve && !thread.isResolved) { - vscodeThread.contextValue = 'canResolve'; - } else if (thread.viewerCanUnresolve && thread.isResolved) { - vscodeThread.contextValue = 'canUnresolve'; - } - - updateCommentThreadLabel(vscodeThread as GHPRCommentThread); - vscodeThread.collapsibleState = getCommentCollapsibleState(thread, undefined, currentUser); - - return vscodeThread as GHPRCommentThread; -} - -function isResolvedToResolvedState(isResolved: boolean) { - return isResolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; -} - -export const COMMENT_EXPAND_STATE_SETTING = 'commentExpandState'; -export const COMMENT_EXPAND_STATE_COLLAPSE_VALUE = 'collapseAll'; -export const COMMENT_EXPAND_STATE_EXPAND_VALUE = 'expandUnresolved'; -export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string) { - if (thread.isResolved - || (currentUser && (thread.comments[thread.comments.length - 1].user?.login === currentUser))) { - return vscode.CommentThreadCollapsibleState.Collapsed; - } - if (expand === undefined) { - const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); - expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; - } - return expand - ? vscode.CommentThreadCollapsibleState.Expanded : vscode.CommentThreadCollapsibleState.Collapsed; -} - - -export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean) { - if (!vscodeThread.range) { - return; - } - const editors = vscode.window.visibleTextEditors; - for (let editor of editors) { - if (editor.document.uri.toString() === vscodeThread.uri.toString()) { - const endLine = editor.document.lineAt(vscodeThread.range.end.line); - const range = new vscode.Range(vscodeThread.range.start.line, 0, vscodeThread.range.end.line, endLine.text.length); - updateThread(context, vscodeThread, reviewThread, githubRepository, expand, range); - break; - } - } -} - -export function updateThread(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean, range?: vscode.Range) { - if (reviewThread.viewerCanResolve && !reviewThread.isResolved) { - vscodeThread.contextValue = 'canResolve'; - } else if (reviewThread.viewerCanUnresolve && reviewThread.isResolved) { - vscodeThread.contextValue = 'canUnresolve'; - } - - const newResolvedState = isResolvedToResolvedState(reviewThread.isResolved); - if (vscodeThread.state !== newResolvedState) { - vscodeThread.state = newResolvedState; - } - vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand); - if (range) { - vscodeThread.range = range; - } - if ((vscodeThread.comments.length === reviewThread.comments.length) && vscodeThread.comments.every((vscodeComment, index) => vscodeComment.commentId === `${reviewThread.comments[index].id}`)) { - // The comments all still exist. Update them instead of creating new ones. This allows the UI to be more stable. - let index = 0; - for (const comment of vscodeThread.comments) { - if (comment instanceof GHPRComment) { - comment.update(reviewThread.comments[index]); - } - index++; - } - } else { - vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(context, c, vscodeThread, githubRepository)); - } - updateCommentThreadLabel(vscodeThread); -} - -export function updateCommentThreadLabel(thread: GHPRCommentThread) { - if (thread.state === vscode.CommentThreadState.Resolved) { - thread.label = vscode.l10n.t('Marked as resolved'); - return; - } - - if (thread.comments.length) { - const participantsList = uniqBy(thread.comments as vscode.Comment[], comment => comment.author.name) - .map(comment => `@${comment.author.name}`) - .join(', '); - thread.label = vscode.l10n.t('Participants: {0}', participantsList); - } else { - thread.label = vscode.l10n.t('Start discussion'); - } -} - -export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { - let reactionsHaveUpdates = false; - const previousReactions = comment.reactions; - const newReactions = getReactionGroup().map((reaction, index) => { - if (!reactions) { - return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; - } - - const matchedReaction = reactions.find(re => re.label === reaction.label); - let newReaction: vscode.CommentReaction; - if (matchedReaction) { - newReaction = { - label: matchedReaction.label, - authorHasReacted: matchedReaction.viewerHasReacted, - count: matchedReaction.count, - iconPath: reaction.icon || '', - reactors: matchedReaction.reactors - }; - } else { - newReaction = { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; - } - if (!reactionsHaveUpdates && (!previousReactions || (previousReactions[index].authorHasReacted !== newReaction.authorHasReacted) || (previousReactions[index].count !== newReaction.count))) { - reactionsHaveUpdates = true; - } - return newReaction; - }); - comment.reactions = newReactions; - return reactionsHaveUpdates; -} - -export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { - if (newDraftMode) { - return; - } - - thread.comments = thread.comments.map(comment => { - if (comment instanceof GHPRComment) { - comment.rawComment.isDraft = false; - } - - comment.label = undefined; - - return comment; - }); -} - -export function isEnterprise(provider: AuthProvider): boolean { - return provider === AuthProvider.githubEnterprise; -} - -export function convertRESTUserToAccount( - user: OctokitCommon.PullsListResponseItemUser, - githubRepository?: GitHubRepository, -): IAccount { - return { - login: user.login, - url: user.html_url, - avatarUrl: githubRepository ? getAvatarWithEnterpriseFallback(user.avatar_url, user.gravatar_id ?? undefined, githubRepository.remote.isEnterprise) : user.avatar_url, - id: user.node_id - }; -} - -export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead): IGitHubRef { - return { - label: head.label, - ref: head.ref, - sha: head.sha, - repo: { - cloneUrl: head.repo.clone_url, - isInOrganization: !!head.repo.organization, - owner: head.repo.owner!.login, - name: head.repo.name - }, - }; -} - -export function convertRESTPullRequestToRawPullRequest( - pullRequest: - | OctokitCommon.PullsGetResponseData - | OctokitCommon.PullsListResponseItem, - githubRepository: GitHubRepository, -): PullRequest { - const { - number, - body, - title, - html_url, - user, - state, - assignees, - created_at, - updated_at, - head, - base, - labels, - node_id, - id, - draft, - } = pullRequest; - - const item: PullRequest = { - id, - graphNodeId: node_id, - number, - body: body ?? '', - title, - titleHTML: title, - url: html_url, - user: convertRESTUserToAccount(user!, githubRepository), - state, - merged: (pullRequest as OctokitCommon.PullsGetResponseData).merged || false, - assignees: assignees - ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) - : undefined, - createdAt: created_at, - updatedAt: updated_at, - head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined, - base: convertRESTHeadToIGitHubRef(base), - labels: labels.map(l => ({ name: '', color: '', ...l })), - isDraft: draft, - suggestedReviewers: [], // suggested reviewers only available through GraphQL API - projectItems: [], // projects only available through GraphQL API - commits: [], // commits only available through GraphQL API - }; - - // mergeable is not included in the list response, will need to fetch later - if ('mergeable' in pullRequest) { - item.mergeable = pullRequest.mergeable - ? PullRequestMergeability.Mergeable - : PullRequestMergeability.NotMergeable; - } - - return item; -} - -export function convertRESTIssueToRawPullRequest( - pullRequest: OctokitCommon.IssuesCreateResponseData, - githubRepository: GitHubRepository, -): PullRequest { - const { - number, - body, - title, - html_url, - user, - state, - assignees, - created_at, - updated_at, - labels, - node_id, - id, - } = pullRequest; - - const item: PullRequest = { - id, - graphNodeId: node_id, - number, - body: body ?? '', - title, - titleHTML: title, - url: html_url, - user: convertRESTUserToAccount(user!, githubRepository), - state, - assignees: assignees - ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) - : undefined, - createdAt: created_at, - updatedAt: updated_at, - labels: labels.map(l => - typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, - ), - suggestedReviewers: [], // suggested reviewers only available through GraphQL API, - projectItems: [], // projects only available through GraphQL API - commits: [], // commits only available through GraphQL API - }; - - return item; -} - -export function convertRESTReviewEvent( - review: OctokitCommon.PullsCreateReviewResponseData, - githubRepository: GitHubRepository, -): Common.ReviewEvent { - return { - event: Common.EventType.Reviewed, - comments: [], - submittedAt: (review as any).submitted_at, // TODO fix typings upstream - body: review.body, - bodyHTML: review.body, - htmlUrl: review.html_url, - user: convertRESTUserToAccount(review.user!, githubRepository), - authorAssociation: review.user!.type, - state: review.state as 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING', - id: review.id, - }; -} - -export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { - const diffHunks: DiffHunk[] = []; - const diffHunkReader = parseDiffHunk(comment.diffHunk); - let diffHunkIter = diffHunkReader.next(); - - while (!diffHunkIter.done) { - const diffHunk = diffHunkIter.value; - diffHunks.push(diffHunk); - diffHunkIter = diffHunkReader.next(); - } - - return diffHunks; -} - -export function convertGraphQLEventType(text: string) { - switch (text) { - case 'PullRequestCommit': - return Common.EventType.Committed; - case 'LabeledEvent': - return Common.EventType.Labeled; - case 'MilestonedEvent': - return Common.EventType.Milestoned; - case 'AssignedEvent': - return Common.EventType.Assigned; - case 'HeadRefDeletedEvent': - return Common.EventType.HeadRefDeleted; - case 'IssueComment': - return Common.EventType.Commented; - case 'PullRequestReview': - return Common.EventType.Reviewed; - case 'MergedEvent': - return Common.EventType.Merged; - - default: - return Common.EventType.Other; - } -} - -export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRepository: GitHubRepository): IReviewThread { - return { - id: thread.id, - prReviewDatabaseId: thread.comments.edges && thread.comments.edges.length ? - thread.comments.edges[0].node.pullRequestReview.databaseId : - undefined, - isResolved: thread.isResolved, - viewerCanResolve: thread.viewerCanResolve, - viewerCanUnresolve: thread.viewerCanUnresolve, - path: thread.path, - startLine: thread.startLine ?? thread.line, - endLine: thread.line, - originalStartLine: thread.originalStartLine ?? thread.originalLine, - originalEndLine: thread.originalLine, - diffSide: thread.diffSide, - isOutdated: thread.isOutdated, - comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, githubRepository)), - subjectType: thread.subjectType ?? SubjectType.LINE - }; -} - -export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, githubRepository: GitHubRepository): IComment { - const c: IComment = { - id: comment.databaseId, - url: comment.url, - body: comment.body, - bodyHTML: comment.bodyHTML, - path: comment.path, - canEdit: comment.viewerCanDelete, - canDelete: comment.viewerCanDelete, - pullRequestReviewId: comment.pullRequestReview && comment.pullRequestReview.databaseId, - diffHunk: comment.diffHunk, - position: comment.position, - commitId: comment.commit.oid, - originalPosition: comment.originalPosition, - originalCommitId: comment.originalCommit && comment.originalCommit.oid, - user: comment.author ? parseAuthor(comment.author, githubRepository) : undefined, - createdAt: comment.createdAt, - htmlUrl: comment.url, - graphNodeId: comment.id, - isDraft: comment.state === 'PENDING', - inReplyToId: comment.replyTo && comment.replyTo.databaseId, - reactions: parseGraphQLReaction(comment.reactionGroups), - isResolved, - }; - - const diffHunks = parseCommentDiffHunk(c); - c.diffHunks = diffHunks; - - return c; -} - -export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRepository: GitHubRepository): IComment { - return { - id: comment.databaseId, - url: comment.url, - body: comment.body, - bodyHTML: comment.bodyHTML, - canEdit: comment.viewerCanDelete, - canDelete: comment.viewerCanDelete, - user: parseAuthor(comment.author, githubRepository), - createdAt: comment.createdAt, - htmlUrl: comment.url, - graphNodeId: comment.id, - diffHunk: '', - }; -} - -export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): Reaction[] { - const reactionContentEmojiMapping = getReactionGroup().reduce((prev, curr) => { - prev[curr.title] = curr; - return prev; - }, {} as { [key: string]: { title: string; label: string; icon?: vscode.Uri } }); - - const reactions = reactionGroups - .filter(group => group.reactors.totalCount > 0) - .map(group => { - const reaction: Reaction = { - label: reactionContentEmojiMapping[group.content].label, - count: group.reactors.totalCount, - icon: reactionContentEmojiMapping[group.content].icon, - viewerHasReacted: group.viewerHasReacted, - reactors: group.reactors.nodes.map(node => node.login) - }; - - return reaction; - }); - - return reactions; -} - -function parseRef(refName: string, oid: string, repository?: GraphQL.RefRepository): IGitHubRef | undefined { - if (!repository) { - return undefined; - } - - return { - label: `${repository.owner.login}:${refName}`, - ref: refName, - sha: oid, - repo: { - cloneUrl: repository.url, - isInOrganization: repository.isInOrganization, - owner: repository.owner.login, - name: refName - }, - }; -} - -function parseAuthor( - author: { login: string; url: string; avatarUrl: string; email?: string, id: string } | null, - githubRepository: GitHubRepository, -): IAccount { - if (author) { - return { - login: author.login, - url: author.url, - avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), - email: author.email, - id: author.id - }; - } else { - return { - login: '', - url: '', - id: '' - }; - } -} - -function parseActor( - author: { login: string; url: string; avatarUrl: string; } | null, - githubRepository: GitHubRepository, -): IActor { - if (author) { - return { - login: author.login, - url: author.url, - avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), - }; - } else { - return { - login: '', - url: '', - }; - } -} - -export function parseProjectItems(projects: { id: string; project: { id: string; title: string; } }[] | undefined): IProjectItem[] | undefined { - if (!projects) { - return undefined; - } - return projects.map(project => { - return { - id: project.id, - project: project.project - }; - }); -} - -export function parseMilestone( - milestone: { title: string; dueOn?: string; createdAt: string; id: string, number: number } | undefined, -): IMilestone | undefined { - if (!milestone) { - return undefined; - } - return { - title: milestone.title, - dueOn: milestone.dueOn, - createdAt: milestone.createdAt, - id: milestone.id, - number: milestone.number - }; -} - -export function parseMergeQueueEntry(mergeQueueEntry: GraphQL.MergeQueueEntry | null | undefined): MergeQueueEntry | undefined | null { - if (!mergeQueueEntry) { - return null; - } - let state: MergeQueueState; - switch (mergeQueueEntry.state) { - case 'AWAITING_CHECKS': { - state = MergeQueueState.AwaitingChecks; - break; - } - case 'LOCKED': { - state = MergeQueueState.Locked; - break; - } - case 'QUEUED': { - state = MergeQueueState.Queued; - break; - } - case 'MERGEABLE': { - state = MergeQueueState.Mergeable; - break; - } - case 'UNMERGEABLE': { - state = MergeQueueState.Unmergeable; - break; - } - } - return { position: mergeQueueEntry.position, state, url: mergeQueueEntry.mergeQueue.url }; -} - -export function parseMergeMethod(mergeMethod: GraphQL.MergeMethod | undefined): MergeMethod | undefined { - switch (mergeMethod) { - case 'MERGE': return 'merge'; - case 'REBASE': return 'rebase'; - case 'SQUASH': return 'squash'; - } -} - -export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING' | undefined, - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE' | undefined): PullRequestMergeability { - let parsed: PullRequestMergeability; - switch (mergeability) { - case undefined: - case 'UNKNOWN': - parsed = PullRequestMergeability.Unknown; - break; - case 'MERGEABLE': - parsed = PullRequestMergeability.Mergeable; - break; - case 'CONFLICTING': - parsed = PullRequestMergeability.Conflict; - break; - } - if (parsed !== PullRequestMergeability.Conflict) { - if (mergeStateStatus === 'BLOCKED') { - parsed = PullRequestMergeability.NotMergeable; - } else if (mergeStateStatus === 'BEHIND') { - parsed = PullRequestMergeability.Behind; - } - } - return parsed; -} - -export function parseGraphQLPullRequest( - graphQLPullRequest: GraphQL.PullRequest, - githubRepository: GitHubRepository, -): PullRequest { - const pr: PullRequest = { - id: graphQLPullRequest.databaseId, - graphNodeId: graphQLPullRequest.id, - url: graphQLPullRequest.url, - number: graphQLPullRequest.number, - state: graphQLPullRequest.state, - body: graphQLPullRequest.body, - bodyHTML: graphQLPullRequest.bodyHTML, - title: graphQLPullRequest.title, - titleHTML: graphQLPullRequest.titleHTML, - createdAt: graphQLPullRequest.createdAt, - updatedAt: graphQLPullRequest.updatedAt, - isRemoteHeadDeleted: !graphQLPullRequest.headRef, - head: parseRef(graphQLPullRequest.headRef?.name ?? graphQLPullRequest.headRefName, graphQLPullRequest.headRefOid, graphQLPullRequest.headRepository), - isRemoteBaseDeleted: !graphQLPullRequest.baseRef, - base: parseRef(graphQLPullRequest.baseRef?.name ?? graphQLPullRequest.baseRefName, graphQLPullRequest.baseRefOid, graphQLPullRequest.baseRepository), - user: parseAuthor(graphQLPullRequest.author, githubRepository), - merged: graphQLPullRequest.merged, - mergeable: parseMergeability(graphQLPullRequest.mergeable, graphQLPullRequest.mergeStateStatus), - mergeQueueEntry: parseMergeQueueEntry(graphQLPullRequest.mergeQueueEntry), - autoMerge: !!graphQLPullRequest.autoMergeRequest, - autoMergeMethod: parseMergeMethod(graphQLPullRequest.autoMergeRequest?.mergeMethod), - allowAutoMerge: graphQLPullRequest.viewerCanEnableAutoMerge || graphQLPullRequest.viewerCanDisableAutoMerge, - labels: graphQLPullRequest.labels.nodes, - isDraft: graphQLPullRequest.isDraft, - suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), - comments: parseComments(graphQLPullRequest.comments?.nodes, githubRepository), - projectItems: parseProjectItems(graphQLPullRequest.projectItems?.nodes), - milestone: parseMilestone(graphQLPullRequest.milestone), - assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), - commits: parseCommits(graphQLPullRequest.commits.nodes), - }; - pr.mergeCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.mergeCommitTitle, graphQLPullRequest.baseRepository.mergeCommitMessage, pr); - pr.squashCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.squashMergeCommitTitle, graphQLPullRequest.baseRepository.squashMergeCommitMessage, pr); - return pr; -} - -function parseCommitMeta(titleSource: GraphQL.DefaultCommitTitle | undefined, descriptionSource: GraphQL.DefaultCommitMessage | undefined, pullRequest: PullRequest): { title: string, description: string } | undefined { - if (titleSource === undefined || descriptionSource === undefined) { - return undefined; - } - - let title = ''; - let description = ''; - - switch (titleSource) { - case GraphQL.DefaultCommitTitle.prTitle: { - title = `${pullRequest.title} (#${pullRequest.number})`; - break; - } - case GraphQL.DefaultCommitTitle.mergeMessage: { - title = `Merge pull request #${pullRequest.number} from ${pullRequest.head?.label ?? ''}`; - break; - } - case GraphQL.DefaultCommitTitle.commitOrPrTitle: { - if (pullRequest.commits.length === 1) { - title = pullRequest.commits[0].message; - } else { - title = pullRequest.title; - } - break; - } - } - switch (descriptionSource) { - case GraphQL.DefaultCommitMessage.prBody: { - description = pullRequest.body; - break; - } - case GraphQL.DefaultCommitMessage.commitMessages: { - description = pullRequest.commits.map(commit => `* ${commit.message}`).join('\n\n'); - break; - } - case GraphQL.DefaultCommitMessage.prTitle: { - description = pullRequest.title; - break; - } - } - return { title, description }; -} - -function parseCommits(commits: { commit: { message: string; }; }[]): { message: string; }[] { - return commits.map(commit => { - return { - message: commit.commit.message - }; - }); -} - -function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, githubRepository: GitHubRepository) { - if (!comments) { - return; - } - const parsedComments: { - author: IAccount; - body: string; - databaseId: number; - }[] = []; - for (const comment of comments) { - parsedComments.push({ - author: parseAuthor(comment.author, githubRepository), - body: comment.body, - databaseId: comment.databaseId, - }); - } - - return parsedComments; -} - -export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: GitHubRepository): Issue { - return { - id: issue.databaseId, - graphNodeId: issue.id, - url: issue.url, - number: issue.number, - state: issue.state, - body: issue.body, - bodyHTML: issue.bodyHTML, - title: issue.title, - titleHTML: issue.titleHTML, - createdAt: issue.createdAt, - updatedAt: issue.updatedAt, - assignees: issue.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), - user: parseAuthor(issue.author, githubRepository), - labels: issue.labels.nodes, - repositoryName: issue.repository?.name ?? githubRepository.remote.repositoryName, - repositoryOwner: issue.repository?.owner.login ?? githubRepository.remote.owner, - repositoryUrl: issue.repository?.url ?? githubRepository.remote.url, - projectItems: parseProjectItems(issue.projectItems?.nodes), - }; -} - -function parseSuggestedReviewers( - suggestedReviewers: GraphQL.SuggestedReviewerResponse[] | undefined, -): ISuggestedReviewer[] { - if (!suggestedReviewers) { - return []; - } - const ret: ISuggestedReviewer[] = suggestedReviewers.map(suggestedReviewer => { - return { - login: suggestedReviewer.reviewer.login, - avatarUrl: suggestedReviewer.reviewer.avatarUrl, - name: suggestedReviewer.reviewer.name, - url: suggestedReviewer.reviewer.url, - isAuthor: suggestedReviewer.isAuthor, - isCommenter: suggestedReviewer.isCommenter, - id: suggestedReviewer.reviewer.id - }; - }); - - return ret.sort(loginComparator); -} - -/** - * Used for case insensitive sort by login - */ -export function loginComparator(a: IAccount, b: IAccount) { - // sensitivity: 'accent' allows case insensitive comparison - return a.login.localeCompare(b.login, 'en', { sensitivity: 'accent' }); -} -/** - * Used for case insensitive sort by team name - */ -export function teamComparator(a: ITeam, b: ITeam) { - const aKey = a.name ?? a.slug; - const bKey = b.name ?? b.slug; - // sensitivity: 'accent' allows case insensitive comparison - return aKey.localeCompare(bKey, 'en', { sensitivity: 'accent' }); -} - -export function parseGraphQLReviewEvent( - review: GraphQL.SubmittedReview, - githubRepository: GitHubRepository, -): Common.ReviewEvent { - return { - event: Common.EventType.Reviewed, - comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, githubRepository)).filter(c => !c.inReplyToId), - submittedAt: review.submittedAt, - body: review.body, - bodyHTML: review.bodyHTML, - htmlUrl: review.url, - user: parseAuthor(review.author, githubRepository), - authorAssociation: review.authorAssociation, - state: review.state, - id: review.databaseId, - }; -} - -export function parseGraphQLTimelineEvents( - events: ( - | GraphQL.MergedEvent - | GraphQL.Review - | GraphQL.IssueComment - | GraphQL.Commit - | GraphQL.AssignedEvent - | GraphQL.HeadRefDeletedEvent - )[], - githubRepository: GitHubRepository, -): Common.TimelineEvent[] { - const normalizedEvents: Common.TimelineEvent[] = []; - events.forEach(event => { - const type = convertGraphQLEventType(event.__typename); - - switch (type) { - case Common.EventType.Commented: - const commentEvent = event as GraphQL.IssueComment; - normalizedEvents.push({ - htmlUrl: commentEvent.url, - body: commentEvent.body, - bodyHTML: commentEvent.bodyHTML, - user: parseAuthor(commentEvent.author, githubRepository), - event: type, - canEdit: commentEvent.viewerCanUpdate, - canDelete: commentEvent.viewerCanDelete, - id: commentEvent.databaseId, - graphNodeId: commentEvent.id, - createdAt: commentEvent.createdAt, - }); - return; - case Common.EventType.Reviewed: - const reviewEvent = event as GraphQL.Review; - normalizedEvents.push({ - event: type, - comments: [], - submittedAt: reviewEvent.submittedAt, - body: reviewEvent.body, - bodyHTML: reviewEvent.bodyHTML, - htmlUrl: reviewEvent.url, - user: parseAuthor(reviewEvent.author, githubRepository), - authorAssociation: reviewEvent.authorAssociation, - state: reviewEvent.state, - id: reviewEvent.databaseId, - }); - return; - case Common.EventType.Committed: - const commitEv = event as GraphQL.Commit; - normalizedEvents.push({ - id: commitEv.id, - event: type, - sha: commitEv.commit.oid, - author: commitEv.commit.author.user - ? parseAuthor(commitEv.commit.author.user, githubRepository) - : { login: commitEv.commit.committer.name }, - htmlUrl: commitEv.url, - message: commitEv.commit.message, - authoredDate: new Date(commitEv.commit.authoredDate), - } as Common.CommitEvent); // TODO remove cast - return; - case Common.EventType.Merged: - const mergeEv = event as GraphQL.MergedEvent; - - normalizedEvents.push({ - id: mergeEv.id, - event: type, - user: parseActor(mergeEv.actor, githubRepository), - createdAt: mergeEv.createdAt, - mergeRef: mergeEv.mergeRef.name, - sha: mergeEv.commit.oid, - commitUrl: mergeEv.commit.commitUrl, - url: mergeEv.url, - graphNodeId: mergeEv.id, - }); - return; - case Common.EventType.Assigned: - const assignEv = event as GraphQL.AssignedEvent; - - normalizedEvents.push({ - id: assignEv.id, - event: type, - user: parseAuthor(assignEv.user, githubRepository), - actor: assignEv.actor, - }); - return; - case Common.EventType.HeadRefDeleted: - const deletedEv = event as GraphQL.HeadRefDeletedEvent; - - normalizedEvents.push({ - id: deletedEv.id, - event: type, - actor: parseActor(deletedEv.actor, githubRepository), - createdAt: deletedEv.createdAt, - headRef: deletedEv.headRefName, - }); - return; - default: - break; - } - }); - - return normalizedEvents; -} - -export function parseGraphQLUser(user: GraphQL.UserResponse, githubRepository: GitHubRepository): User { - return { - login: user.user.login, - name: user.user.name, - avatarUrl: getAvatarWithEnterpriseFallback(user.user.avatarUrl ?? '', undefined, githubRepository.remote.isEnterprise), - url: user.user.url, - bio: user.user.bio, - company: user.user.company, - location: user.user.location, - commitContributions: parseGraphQLCommitContributions(user.user.contributionsCollection), - id: user.user.id - }; -} - -function parseGraphQLCommitContributions( - commitComments: GraphQL.ContributionsCollection, -): { createdAt: Date; repoNameWithOwner: string }[] { - const items: { createdAt: Date; repoNameWithOwner: string }[] = []; - commitComments.commitContributionsByRepository.forEach(repoCommits => { - repoCommits.contributions.nodes.forEach(commit => { - items.push({ - createdAt: new Date(commit.occurredAt), - repoNameWithOwner: repoCommits.repository.nameWithOwner, - }); - }); - }); - return items; -} - -export function getReactionGroup(): { title: string; label: string; icon?: vscode.Uri }[] { - const ret = [ - { - title: 'THUMBS_UP', - // allow-any-unicode-next-line - label: '👍', - icon: Resource.icons.reactions.THUMBS_UP, - }, - { - title: 'THUMBS_DOWN', - // allow-any-unicode-next-line - label: '👎', - icon: Resource.icons.reactions.THUMBS_DOWN, - }, - { - title: 'LAUGH', - // allow-any-unicode-next-line - label: '😄', - icon: Resource.icons.reactions.LAUGH, - }, - { - title: 'HOORAY', - // allow-any-unicode-next-line - label: '🎉', - icon: Resource.icons.reactions.HOORAY, - }, - { - title: 'CONFUSED', - // allow-any-unicode-next-line - label: '😕', - icon: Resource.icons.reactions.CONFUSED, - }, - { - title: 'HEART', - // allow-any-unicode-next-line - label: '❤️', - icon: Resource.icons.reactions.HEART, - }, - { - title: 'ROCKET', - // allow-any-unicode-next-line - label: '🚀', - icon: Resource.icons.reactions.ROCKET, - }, - { - title: 'EYES', - // allow-any-unicode-next-line - label: '👀', - icon: Resource.icons.reactions.EYES, - }, - ]; - - return ret; -} - -export async function restPaginate(request: R, variables: Parameters[0]): Promise { - let page = 1; - let results: T[] = []; - let hasNextPage = false; - - do { - const result = await request( - { - ...(variables as any), - per_page: 100, - page - } - ); - - results = results.concat( - result.data as T[] - ); - - hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; - page += 1; - } while (hasNextPage); - - return results; -} - -export function getRelatedUsersFromTimelineEvents( - timelineEvents: Common.TimelineEvent[], -): { login: string; name: string }[] { - const ret: { login: string; name: string }[] = []; - - timelineEvents.forEach(event => { - if (event.event === Common.EventType.Committed) { - ret.push({ - login: event.author.login, - name: event.author.name || '', - }); - } - - if (event.event === Common.EventType.Reviewed) { - ret.push({ - login: event.user.login, - name: event.user.name ?? event.user.login, - }); - } - - if (event.event === Common.EventType.Commented) { - ret.push({ - login: event.user.login, - name: event.user.name ?? event.user.login, - }); - } - }); - - return ret; -} - -export function parseGraphQLViewerPermission( - viewerPermissionResponse: GraphQL.ViewerPermissionResponse, -): ViewerPermission { - if (viewerPermissionResponse && viewerPermissionResponse.repository?.viewerPermission) { - if ( - (Object.values(ViewerPermission) as string[]).includes(viewerPermissionResponse.repository.viewerPermission) - ) { - return viewerPermissionResponse.repository.viewerPermission as ViewerPermission; - } - } - return ViewerPermission.Unknown; -} - -export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean { - return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || - (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && - file.path.substring(repository.rootUri.path.length).startsWith('/')); -} - -export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repository | undefined { - const foundRepos: Repository[] = []; - for (const repository of gitAPI.repositories.reverse()) { - if (isFileInRepo(repository, file)) { - foundRepos.push(repository); - } - } - if (foundRepos.length > 0) { - foundRepos.sort((a, b) => b.rootUri.path.length - a.rootUri.path.length); - return foundRepos[0]; - } - return undefined; -} - -/** - * Create a list of reviewers composed of people who have already left reviews on the PR, and - * those that have had a review requested of them. If a reviewer has left multiple reviews, the - * state should be the state of their most recent review, or 'REQUESTED' if they have an outstanding - * review request. - * @param requestedReviewers The list of reviewers that are requested for this pull request - * @param timelineEvents All timeline events for the pull request - * @param author The author of the pull request - */ -export function parseReviewers( - requestedReviewers: (IAccount | ITeam)[], - timelineEvents: Common.TimelineEvent[], - author: IAccount, -): ReviewState[] { - const reviewEvents = timelineEvents.filter((e): e is Common.ReviewEvent => e.event === Common.EventType.Reviewed).filter(event => event.state !== 'PENDING'); - let reviewers: ReviewState[] = []; - const seen = new Map(); - - // Do not show the author in the reviewer list - seen.set(author.login, true); - - for (let i = reviewEvents.length - 1; i >= 0; i--) { - const reviewer = reviewEvents[i].user; - if (!seen.get(reviewer.login)) { - seen.set(reviewer.login, true); - reviewers.push({ - reviewer: reviewer, - state: reviewEvents[i].state, - }); - } - } - - requestedReviewers.forEach(request => { - if (!seen.get(reviewerId(request))) { - reviewers.push({ - reviewer: request, - state: 'REQUESTED', - }); - } else { - const reviewer = reviewers.find(r => reviewerId(r.reviewer) === reviewerId(request)); - reviewer!.state = 'REQUESTED'; - } - }); - - // Put completed reviews before review requests and alphabetize each section - reviewers = reviewers.sort((a, b) => { - if (a.state === 'REQUESTED' && b.state !== 'REQUESTED') { - return 1; - } - - if (b.state === 'REQUESTED' && a.state !== 'REQUESTED') { - return -1; - } - - return reviewerLabel(a.reviewer).toLowerCase() < reviewerLabel(b.reviewer).toLowerCase() ? -1 : 1; - }); - - return reviewers; -} - -export function insertNewCommitsSinceReview( - timelineEvents: Common.TimelineEvent[], - latestReviewCommitOid: string | undefined, - currentUser: string, - head: GitHubRef | null -) { - if (latestReviewCommitOid && head && head.sha !== latestReviewCommitOid) { - let lastViewerReviewIndex: number = timelineEvents.length - 1; - let comittedDuringReview: boolean = false; - let interReviewCommits: Common.TimelineEvent[] = []; - - for (let i = timelineEvents.length - 1; i > 0; i--) { - if ( - timelineEvents[i].event === Common.EventType.Committed && - (timelineEvents[i] as Common.CommitEvent).sha === latestReviewCommitOid - ) { - interReviewCommits.unshift({ - id: latestReviewCommitOid, - event: Common.EventType.NewCommitsSinceReview - }); - timelineEvents.splice(lastViewerReviewIndex + 1, 0, ...interReviewCommits); - break; - } - else if (comittedDuringReview && timelineEvents[i].event === Common.EventType.Committed) { - interReviewCommits.unshift(timelineEvents[i]); - timelineEvents.splice(i, 1); - } - else if ( - !comittedDuringReview && - timelineEvents[i].event === Common.EventType.Reviewed && - (timelineEvents[i] as Common.ReviewEvent).user.login === currentUser - ) { - lastViewerReviewIndex = i; - comittedDuringReview = true; - } - } - } -} - -export function getPRFetchQuery(repo: string, user: string, query: string): string { - const filter = query.replace(/\$\{user\}/g, user); - return `is:pull-request ${filter} type:pr repo:${repo}`; -} - -export function isInCodespaces(): boolean { - return vscode.env.remoteName === 'codespaces' && vscode.env.uiKind === vscode.UIKind.Web; -} - -export async function setEnterpriseUri(host: string) { - return vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).update(URI, host, vscode.ConfigurationTarget.Workspace); -} - -export function getEnterpriseUri(): vscode.Uri | undefined { - const config: string = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); - if (config) { - let uri = vscode.Uri.parse(config, true); - if (uri.scheme === 'http') { - uri = uri.with({ scheme: 'https' }); - } - return uri; - } -} - -export function hasEnterpriseUri(): boolean { - return !!getEnterpriseUri(); -} - -export function generateGravatarUrl(gravatarId: string | undefined, size: number = 200): string | undefined { - return !!gravatarId ? `https://www.gravatar.com/avatar/${gravatarId}?s=${size}&d=retro` : undefined; -} - -export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, isEnterpriseRemote: boolean): string | undefined { - return !isEnterpriseRemote ? avatarUrl : (email ? generateGravatarUrl( - crypto.createHash('md5').update(email?.trim()?.toLowerCase()).digest('hex')) : undefined); -} - -export function getPullsUrl(repo: GitHubRepository) { - return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/pulls`); -} - -export function getIssuesUrl(repo: GitHubRepository) { - return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/issues`); -} - -export function sanitizeIssueTitle(title: string): string { - const regex = /[~^:;'".,~#?%*&[\]@\\{}()/]|\/\//g; - - return title.replace(regex, '').trim().substring(0, 150).replace(/\s+/g, '-'); -} - -const VARIABLE_PATTERN = /\$\{(.*?)\}/g; -export async function variableSubstitution( - value: string, - issueModel?: IssueModel, - defaults?: PullRequestDefaults, - user?: string, -): Promise { - return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { - switch (variable) { - case 'user': - return user ? user : match; - case 'issueNumber': - return issueModel ? `${issueModel.number}` : match; - case 'issueNumberLabel': - return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; - case 'issueTitle': - return issueModel ? issueModel.title : match; - case 'repository': - return defaults ? defaults.repo : match; - case 'owner': - return defaults ? defaults.owner : match; - case 'sanitizedIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted - case 'sanitizedLowercaseIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; - default: - return match; - } - }); -} - -export function getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { - const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; - if ( - repo && - (repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase() || - repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()) - ) { - parsedIssue.owner = issue.remote.owner; - parsedIssue.name = issue.remote.repositoryName; - } - return getIssueNumberLabelFromParsed(parsedIssue); -} - -export function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { - if (!parsed.owner || !parsed.name) { - return `#${parsed.issueNumber}`; - } else { - return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; - } -} - -export function getOverrideBranch(): string | undefined { - const overrideSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(OVERRIDE_DEFAULT_BRANCH); - if (overrideSetting) { - Logger.debug('Using override setting for default branch', GitHubRepository.ID); - return overrideSetting; - } -} - -export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepositoryManager[]): Promise<{ dotComRemotes: Remote[], enterpriseRemotes: Remote[], unknownRemotes: Remote[] }> { - // Check if we have found any github.com remotes - const dotComRemotes: Remote[] = []; - const enterpriseRemotes: Remote[] = []; - const unknownRemotes: Remote[] = []; - for (const manager of folderManagers) { - for (const remote of await manager.computeAllGitHubRemotes()) { - if (remote.githubServerType === GitHubServerType.GitHubDotCom) { - dotComRemotes.push(remote); - } else if (remote.githubServerType === GitHubServerType.Enterprise) { - enterpriseRemotes.push(remote); - } - } - unknownRemotes.push(...await manager.computeAllUnknownRemotes()); - } - return { dotComRemotes, enterpriseRemotes, unknownRemotes }; -} - -export function vscodeDevPrLink(pullRequest: PullRequestModel) { - const itemUri = vscode.Uri.parse(pullRequest.html_url); - return `https://${vscode.env.appName.toLowerCase().includes('insider') ? 'insiders.' : ''}vscode.dev/github${itemUri.path}`; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as crypto from 'crypto'; +import * as OctokitTypes from '@octokit/types'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { IComment, IReviewThread, Reaction, SubjectType } from '../common/comment'; +import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { Resource } from '../common/resources'; +import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; +import * as Common from '../common/timelineEvent'; +import { uniqBy } from '../common/utils'; +import { OctokitCommon } from './common'; +import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; +import { GitHubRepository, ViewerPermission } from './githubRepository'; +import * as GraphQL from './graphql'; +import { + IAccount, + IActor, + IGitHubRef, + ILabel, + IMilestone, + IProjectItem, + Issue, + ISuggestedReviewer, + ITeam, + MergeMethod, + MergeQueueEntry, + MergeQueueState, + PullRequest, + PullRequestMergeability, + reviewerId, + reviewerLabel, + ReviewState, + User, +} from './interface'; +import { IssueModel } from './issueModel'; +import { GHPRComment, GHPRCommentThread } from './prComment'; +import { PullRequestModel } from './pullRequestModel'; + +export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; +export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; + +export interface CommentReactionHandler { + toggleReaction(comment: vscode.Comment, reaction: vscode.CommentReaction): Promise; +} + +export type ParsedIssue = { + owner: string | undefined; + name: string | undefined; + issueNumber: number; + commentNumber?: number; +}; + +export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { + if (!output) { + return undefined; + } + const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; + if (output.length === 7) { + issue.owner = output[2]; + issue.name = output[3]; + issue.issueNumber = parseInt(output[5]); + return issue; + } else if (output.length === 16) { + issue.owner = output[3] || output[11]; + issue.name = output[4] || output[12]; + issue.issueNumber = parseInt(output[7] || output[14]); + issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; + return issue; + } else { + return undefined; + } +} + +export function threadRange(startLine: number, endLine: number, endCharacter?: number): vscode.Range { + if ((startLine !== endLine) && (endCharacter === undefined)) { + endCharacter = 300; // 300 is a "large" number that will select a lot of the line since don't know anything about the line length + } else if (!endCharacter) { + endCharacter = 0; + } + return new vscode.Range(startLine, 0, endLine, endCharacter); +} + +export function createVSCodeCommentThreadForReviewThread( + context: vscode.ExtensionContext, + uri: vscode.Uri, + range: vscode.Range | undefined, + thread: IReviewThread, + commentController: vscode.CommentController, + currentUser: string, + githubRepository?: GitHubRepository +): GHPRCommentThread { + const vscodeThread = commentController.createCommentThread(uri, range, []); + + (vscodeThread as GHPRCommentThread).gitHubThreadId = thread.id; + + vscodeThread.comments = thread.comments.map(comment => new GHPRComment(context, comment, vscodeThread as GHPRCommentThread, githubRepository)); + vscodeThread.state = isResolvedToResolvedState(thread.isResolved); + + if (thread.viewerCanResolve && !thread.isResolved) { + vscodeThread.contextValue = 'canResolve'; + } else if (thread.viewerCanUnresolve && thread.isResolved) { + vscodeThread.contextValue = 'canUnresolve'; + } + + updateCommentThreadLabel(vscodeThread as GHPRCommentThread); + vscodeThread.collapsibleState = getCommentCollapsibleState(thread, undefined, currentUser); + + return vscodeThread as GHPRCommentThread; +} + +function isResolvedToResolvedState(isResolved: boolean) { + return isResolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; +} + +export const COMMENT_EXPAND_STATE_SETTING = 'commentExpandState'; +export const COMMENT_EXPAND_STATE_COLLAPSE_VALUE = 'collapseAll'; +export const COMMENT_EXPAND_STATE_EXPAND_VALUE = 'expandUnresolved'; +export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string) { + if (thread.isResolved + || (currentUser && (thread.comments[thread.comments.length - 1].user?.login === currentUser))) { + return vscode.CommentThreadCollapsibleState.Collapsed; + } + if (expand === undefined) { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); + expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; + } + return expand + ? vscode.CommentThreadCollapsibleState.Expanded : vscode.CommentThreadCollapsibleState.Collapsed; +} + + +export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean) { + if (!vscodeThread.range) { + return; + } + const editors = vscode.window.visibleTextEditors; + for (let editor of editors) { + if (editor.document.uri.toString() === vscodeThread.uri.toString()) { + const endLine = editor.document.lineAt(vscodeThread.range.end.line); + const range = new vscode.Range(vscodeThread.range.start.line, 0, vscodeThread.range.end.line, endLine.text.length); + updateThread(context, vscodeThread, reviewThread, githubRepository, expand, range); + break; + } + } +} + +export function updateThread(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean, range?: vscode.Range) { + if (reviewThread.viewerCanResolve && !reviewThread.isResolved) { + vscodeThread.contextValue = 'canResolve'; + } else if (reviewThread.viewerCanUnresolve && reviewThread.isResolved) { + vscodeThread.contextValue = 'canUnresolve'; + } + + const newResolvedState = isResolvedToResolvedState(reviewThread.isResolved); + if (vscodeThread.state !== newResolvedState) { + vscodeThread.state = newResolvedState; + } + vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand); + if (range) { + vscodeThread.range = range; + } + if ((vscodeThread.comments.length === reviewThread.comments.length) && vscodeThread.comments.every((vscodeComment, index) => vscodeComment.commentId === `${reviewThread.comments[index].id}`)) { + // The comments all still exist. Update them instead of creating new ones. This allows the UI to be more stable. + let index = 0; + for (const comment of vscodeThread.comments) { + if (comment instanceof GHPRComment) { + comment.update(reviewThread.comments[index]); + } + index++; + } + } else { + vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(context, c, vscodeThread, githubRepository)); + } + updateCommentThreadLabel(vscodeThread); +} + +export function updateCommentThreadLabel(thread: GHPRCommentThread) { + if (thread.state === vscode.CommentThreadState.Resolved) { + thread.label = vscode.l10n.t('Marked as resolved'); + return; + } + + if (thread.comments.length) { + const participantsList = uniqBy(thread.comments as vscode.Comment[], comment => comment.author.name) + .map(comment => `@${comment.author.name}`) + .join(', '); + thread.label = vscode.l10n.t('Participants: {0}', participantsList); + } else { + thread.label = vscode.l10n.t('Start discussion'); + } +} + +export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { + let reactionsHaveUpdates = false; + const previousReactions = comment.reactions; + const newReactions = getReactionGroup().map((reaction, index) => { + if (!reactions) { + return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + } + + const matchedReaction = reactions.find(re => re.label === reaction.label); + let newReaction: vscode.CommentReaction; + if (matchedReaction) { + newReaction = { + label: matchedReaction.label, + authorHasReacted: matchedReaction.viewerHasReacted, + count: matchedReaction.count, + iconPath: reaction.icon || '', + reactors: matchedReaction.reactors + }; + } else { + newReaction = { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + } + if (!reactionsHaveUpdates && (!previousReactions || (previousReactions[index].authorHasReacted !== newReaction.authorHasReacted) || (previousReactions[index].count !== newReaction.count))) { + reactionsHaveUpdates = true; + } + return newReaction; + }); + comment.reactions = newReactions; + return reactionsHaveUpdates; +} + +export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { + if (newDraftMode) { + return; + } + + thread.comments = thread.comments.map(comment => { + if (comment instanceof GHPRComment) { + comment.rawComment.isDraft = false; + } + + comment.label = undefined; + + return comment; + }); +} + +export function isEnterprise(provider: AuthProvider): boolean { + return provider === AuthProvider.githubEnterprise; +} + +export function convertRESTUserToAccount( + user: OctokitCommon.PullsListResponseItemUser, + githubRepository?: GitHubRepository, +): IAccount { + return { + login: user.login, + url: user.html_url, + avatarUrl: githubRepository ? getAvatarWithEnterpriseFallback(user.avatar_url, user.gravatar_id ?? undefined, githubRepository.remote.isEnterprise) : user.avatar_url, + id: user.node_id + }; +} + +export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead): IGitHubRef { + return { + label: head.label, + ref: head.ref, + sha: head.sha, + repo: { + cloneUrl: head.repo.clone_url, + isInOrganization: !!head.repo.organization, + owner: head.repo.owner!.login, + name: head.repo.name + }, + }; +} + +export function convertRESTPullRequestToRawPullRequest( + pullRequest: + | OctokitCommon.PullsGetResponseData + | OctokitCommon.PullsListResponseItem, + githubRepository: GitHubRepository, +): PullRequest { + const { + number, + body, + title, + html_url, + user, + state, + assignees, + created_at, + updated_at, + head, + base, + labels, + node_id, + id, + draft, + } = pullRequest; + + const item: PullRequest = { + id, + graphNodeId: node_id, + number, + body: body ?? '', + title, + titleHTML: title, + url: html_url, + user: convertRESTUserToAccount(user!, githubRepository), + state, + merged: (pullRequest as OctokitCommon.PullsGetResponseData).merged || false, + assignees: assignees + ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) + : undefined, + createdAt: created_at, + updatedAt: updated_at, + head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined, + base: convertRESTHeadToIGitHubRef(base), + labels: labels.map(l => ({ name: '', color: '', ...l })), + isDraft: draft, + suggestedReviewers: [], // suggested reviewers only available through GraphQL API + projectItems: [], // projects only available through GraphQL API + commits: [], // commits only available through GraphQL API + }; + + // mergeable is not included in the list response, will need to fetch later + if ('mergeable' in pullRequest) { + item.mergeable = pullRequest.mergeable + ? PullRequestMergeability.Mergeable + : PullRequestMergeability.NotMergeable; + } + + return item; +} + +export function convertRESTIssueToRawPullRequest( + pullRequest: OctokitCommon.IssuesCreateResponseData, + githubRepository: GitHubRepository, +): PullRequest { + const { + number, + body, + title, + html_url, + user, + state, + assignees, + created_at, + updated_at, + labels, + node_id, + id, + } = pullRequest; + + const item: PullRequest = { + id, + graphNodeId: node_id, + number, + body: body ?? '', + title, + titleHTML: title, + url: html_url, + user: convertRESTUserToAccount(user!, githubRepository), + state, + assignees: assignees + ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) + : undefined, + createdAt: created_at, + updatedAt: updated_at, + labels: labels.map(l => + typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, + ), + suggestedReviewers: [], // suggested reviewers only available through GraphQL API, + projectItems: [], // projects only available through GraphQL API + commits: [], // commits only available through GraphQL API + }; + + return item; +} + +export function convertRESTReviewEvent( + review: OctokitCommon.PullsCreateReviewResponseData, + githubRepository: GitHubRepository, +): Common.ReviewEvent { + return { + event: Common.EventType.Reviewed, + comments: [], + submittedAt: (review as any).submitted_at, // TODO fix typings upstream + body: review.body, + bodyHTML: review.body, + htmlUrl: review.html_url, + user: convertRESTUserToAccount(review.user!, githubRepository), + authorAssociation: review.user!.type, + state: review.state as 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING', + id: review.id, + }; +} + +export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { + const diffHunks: DiffHunk[] = []; + const diffHunkReader = parseDiffHunk(comment.diffHunk); + let diffHunkIter = diffHunkReader.next(); + + while (!diffHunkIter.done) { + const diffHunk = diffHunkIter.value; + diffHunks.push(diffHunk); + diffHunkIter = diffHunkReader.next(); + } + + return diffHunks; +} + +export function convertGraphQLEventType(text: string) { + switch (text) { + case 'PullRequestCommit': + return Common.EventType.Committed; + case 'LabeledEvent': + return Common.EventType.Labeled; + case 'MilestonedEvent': + return Common.EventType.Milestoned; + case 'AssignedEvent': + return Common.EventType.Assigned; + case 'HeadRefDeletedEvent': + return Common.EventType.HeadRefDeleted; + case 'IssueComment': + return Common.EventType.Commented; + case 'PullRequestReview': + return Common.EventType.Reviewed; + case 'MergedEvent': + return Common.EventType.Merged; + + default: + return Common.EventType.Other; + } +} + +export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRepository: GitHubRepository): IReviewThread { + return { + id: thread.id, + prReviewDatabaseId: thread.comments.edges && thread.comments.edges.length ? + thread.comments.edges[0].node.pullRequestReview.databaseId : + undefined, + isResolved: thread.isResolved, + viewerCanResolve: thread.viewerCanResolve, + viewerCanUnresolve: thread.viewerCanUnresolve, + path: thread.path, + startLine: thread.startLine ?? thread.line, + endLine: thread.line, + originalStartLine: thread.originalStartLine ?? thread.originalLine, + originalEndLine: thread.originalLine, + diffSide: thread.diffSide, + isOutdated: thread.isOutdated, + comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, githubRepository)), + subjectType: thread.subjectType ?? SubjectType.LINE + }; +} + +export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, githubRepository: GitHubRepository): IComment { + const c: IComment = { + id: comment.databaseId, + url: comment.url, + body: comment.body, + bodyHTML: comment.bodyHTML, + path: comment.path, + canEdit: comment.viewerCanDelete, + canDelete: comment.viewerCanDelete, + pullRequestReviewId: comment.pullRequestReview && comment.pullRequestReview.databaseId, + diffHunk: comment.diffHunk, + position: comment.position, + commitId: comment.commit.oid, + originalPosition: comment.originalPosition, + originalCommitId: comment.originalCommit && comment.originalCommit.oid, + user: comment.author ? parseAuthor(comment.author, githubRepository) : undefined, + createdAt: comment.createdAt, + htmlUrl: comment.url, + graphNodeId: comment.id, + isDraft: comment.state === 'PENDING', + inReplyToId: comment.replyTo && comment.replyTo.databaseId, + reactions: parseGraphQLReaction(comment.reactionGroups), + isResolved, + }; + + const diffHunks = parseCommentDiffHunk(c); + c.diffHunks = diffHunks; + + return c; +} + +export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRepository: GitHubRepository): IComment { + return { + id: comment.databaseId, + url: comment.url, + body: comment.body, + bodyHTML: comment.bodyHTML, + canEdit: comment.viewerCanDelete, + canDelete: comment.viewerCanDelete, + user: parseAuthor(comment.author, githubRepository), + createdAt: comment.createdAt, + htmlUrl: comment.url, + graphNodeId: comment.id, + diffHunk: '', + }; +} + +export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): Reaction[] { + const reactionContentEmojiMapping = getReactionGroup().reduce((prev, curr) => { + prev[curr.title] = curr; + return prev; + }, {} as { [key: string]: { title: string; label: string; icon?: vscode.Uri } }); + + const reactions = reactionGroups + .filter(group => group.reactors.totalCount > 0) + .map(group => { + const reaction: Reaction = { + label: reactionContentEmojiMapping[group.content].label, + count: group.reactors.totalCount, + icon: reactionContentEmojiMapping[group.content].icon, + viewerHasReacted: group.viewerHasReacted, + reactors: group.reactors.nodes.map(node => node.login) + }; + + return reaction; + }); + + return reactions; +} + +function parseRef(refName: string, oid: string, repository?: GraphQL.RefRepository): IGitHubRef | undefined { + if (!repository) { + return undefined; + } + + return { + label: `${repository.owner.login}:${refName}`, + ref: refName, + sha: oid, + repo: { + cloneUrl: repository.url, + isInOrganization: repository.isInOrganization, + owner: repository.owner.login, + name: refName + }, + }; +} + +function parseAuthor( + author: { login: string; url: string; avatarUrl: string; email?: string, id: string } | null, + githubRepository: GitHubRepository, +): IAccount { + if (author) { + return { + login: author.login, + url: author.url, + avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), + email: author.email, + id: author.id + }; + } else { + return { + login: '', + url: '', + id: '' + }; + } +} + +function parseActor( + author: { login: string; url: string; avatarUrl: string; } | null, + githubRepository: GitHubRepository, +): IActor { + if (author) { + return { + login: author.login, + url: author.url, + avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), + }; + } else { + return { + login: '', + url: '', + }; + } +} + +export function parseProjectItems(projects: { id: string; project: { id: string; title: string; } }[] | undefined): IProjectItem[] | undefined { + if (!projects) { + return undefined; + } + return projects.map(project => { + return { + id: project.id, + project: project.project + }; + }); +} + +export function parseMilestone( + milestone: { title: string; dueOn?: string; createdAt: string; id: string, number: number } | undefined, +): IMilestone | undefined { + if (!milestone) { + return undefined; + } + return { + title: milestone.title, + dueOn: milestone.dueOn, + createdAt: milestone.createdAt, + id: milestone.id, + number: milestone.number + }; +} + +export function parseMergeQueueEntry(mergeQueueEntry: GraphQL.MergeQueueEntry | null | undefined): MergeQueueEntry | undefined | null { + if (!mergeQueueEntry) { + return null; + } + let state: MergeQueueState; + switch (mergeQueueEntry.state) { + case 'AWAITING_CHECKS': { + state = MergeQueueState.AwaitingChecks; + break; + } + case 'LOCKED': { + state = MergeQueueState.Locked; + break; + } + case 'QUEUED': { + state = MergeQueueState.Queued; + break; + } + case 'MERGEABLE': { + state = MergeQueueState.Mergeable; + break; + } + case 'UNMERGEABLE': { + state = MergeQueueState.Unmergeable; + break; + } + } + return { position: mergeQueueEntry.position, state, url: mergeQueueEntry.mergeQueue.url }; +} + +export function parseMergeMethod(mergeMethod: GraphQL.MergeMethod | undefined): MergeMethod | undefined { + switch (mergeMethod) { + case 'MERGE': return 'merge'; + case 'REBASE': return 'rebase'; + case 'SQUASH': return 'squash'; + } +} + +export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING' | undefined, + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE' | undefined): PullRequestMergeability { + let parsed: PullRequestMergeability; + switch (mergeability) { + case undefined: + case 'UNKNOWN': + parsed = PullRequestMergeability.Unknown; + break; + case 'MERGEABLE': + parsed = PullRequestMergeability.Mergeable; + break; + case 'CONFLICTING': + parsed = PullRequestMergeability.Conflict; + break; + } + if (parsed !== PullRequestMergeability.Conflict) { + if (mergeStateStatus === 'BLOCKED') { + parsed = PullRequestMergeability.NotMergeable; + } else if (mergeStateStatus === 'BEHIND') { + parsed = PullRequestMergeability.Behind; + } + } + return parsed; +} + +export function parseGraphQLPullRequest( + graphQLPullRequest: GraphQL.PullRequest, + githubRepository: GitHubRepository, +): PullRequest { + const pr: PullRequest = { + id: graphQLPullRequest.databaseId, + graphNodeId: graphQLPullRequest.id, + url: graphQLPullRequest.url, + number: graphQLPullRequest.number, + state: graphQLPullRequest.state, + body: graphQLPullRequest.body, + bodyHTML: graphQLPullRequest.bodyHTML, + title: graphQLPullRequest.title, + titleHTML: graphQLPullRequest.titleHTML, + createdAt: graphQLPullRequest.createdAt, + updatedAt: graphQLPullRequest.updatedAt, + isRemoteHeadDeleted: !graphQLPullRequest.headRef, + head: parseRef(graphQLPullRequest.headRef?.name ?? graphQLPullRequest.headRefName, graphQLPullRequest.headRefOid, graphQLPullRequest.headRepository), + isRemoteBaseDeleted: !graphQLPullRequest.baseRef, + base: parseRef(graphQLPullRequest.baseRef?.name ?? graphQLPullRequest.baseRefName, graphQLPullRequest.baseRefOid, graphQLPullRequest.baseRepository), + user: parseAuthor(graphQLPullRequest.author, githubRepository), + merged: graphQLPullRequest.merged, + mergeable: parseMergeability(graphQLPullRequest.mergeable, graphQLPullRequest.mergeStateStatus), + mergeQueueEntry: parseMergeQueueEntry(graphQLPullRequest.mergeQueueEntry), + autoMerge: !!graphQLPullRequest.autoMergeRequest, + autoMergeMethod: parseMergeMethod(graphQLPullRequest.autoMergeRequest?.mergeMethod), + allowAutoMerge: graphQLPullRequest.viewerCanEnableAutoMerge || graphQLPullRequest.viewerCanDisableAutoMerge, + labels: graphQLPullRequest.labels.nodes, + isDraft: graphQLPullRequest.isDraft, + suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), + comments: parseComments(graphQLPullRequest.comments?.nodes, githubRepository), + projectItems: parseProjectItems(graphQLPullRequest.projectItems?.nodes), + milestone: parseMilestone(graphQLPullRequest.milestone), + assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), + commits: parseCommits(graphQLPullRequest.commits.nodes), + }; + pr.mergeCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.mergeCommitTitle, graphQLPullRequest.baseRepository.mergeCommitMessage, pr); + pr.squashCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.squashMergeCommitTitle, graphQLPullRequest.baseRepository.squashMergeCommitMessage, pr); + return pr; +} + +function parseCommitMeta(titleSource: GraphQL.DefaultCommitTitle | undefined, descriptionSource: GraphQL.DefaultCommitMessage | undefined, pullRequest: PullRequest): { title: string, description: string } | undefined { + if (titleSource === undefined || descriptionSource === undefined) { + return undefined; + } + + let title = ''; + let description = ''; + + switch (titleSource) { + case GraphQL.DefaultCommitTitle.prTitle: { + title = `${pullRequest.title} (#${pullRequest.number})`; + break; + } + case GraphQL.DefaultCommitTitle.mergeMessage: { + title = `Merge pull request #${pullRequest.number} from ${pullRequest.head?.label ?? ''}`; + break; + } + case GraphQL.DefaultCommitTitle.commitOrPrTitle: { + if (pullRequest.commits.length === 1) { + title = pullRequest.commits[0].message; + } else { + title = pullRequest.title; + } + break; + } + } + switch (descriptionSource) { + case GraphQL.DefaultCommitMessage.prBody: { + description = pullRequest.body; + break; + } + case GraphQL.DefaultCommitMessage.commitMessages: { + description = pullRequest.commits.map(commit => `* ${commit.message}`).join('\n\n'); + break; + } + case GraphQL.DefaultCommitMessage.prTitle: { + description = pullRequest.title; + break; + } + } + return { title, description }; +} + +function parseCommits(commits: { commit: { message: string; }; }[]): { message: string; }[] { + return commits.map(commit => { + return { + message: commit.commit.message + }; + }); +} + +function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, githubRepository: GitHubRepository) { + if (!comments) { + return; + } + const parsedComments: { + author: IAccount; + body: string; + databaseId: number; + }[] = []; + for (const comment of comments) { + parsedComments.push({ + author: parseAuthor(comment.author, githubRepository), + body: comment.body, + databaseId: comment.databaseId, + }); + } + + return parsedComments; +} + +export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: GitHubRepository): Issue { + return { + id: issue.databaseId, + graphNodeId: issue.id, + url: issue.url, + number: issue.number, + state: issue.state, + body: issue.body, + bodyHTML: issue.bodyHTML, + title: issue.title, + titleHTML: issue.titleHTML, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + assignees: issue.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), + user: parseAuthor(issue.author, githubRepository), + labels: issue.labels.nodes, + repositoryName: issue.repository?.name ?? githubRepository.remote.repositoryName, + repositoryOwner: issue.repository?.owner.login ?? githubRepository.remote.owner, + repositoryUrl: issue.repository?.url ?? githubRepository.remote.url, + projectItems: parseProjectItems(issue.projectItems?.nodes), + }; +} + +function parseSuggestedReviewers( + suggestedReviewers: GraphQL.SuggestedReviewerResponse[] | undefined, +): ISuggestedReviewer[] { + if (!suggestedReviewers) { + return []; + } + const ret: ISuggestedReviewer[] = suggestedReviewers.map(suggestedReviewer => { + return { + login: suggestedReviewer.reviewer.login, + avatarUrl: suggestedReviewer.reviewer.avatarUrl, + name: suggestedReviewer.reviewer.name, + url: suggestedReviewer.reviewer.url, + isAuthor: suggestedReviewer.isAuthor, + isCommenter: suggestedReviewer.isCommenter, + id: suggestedReviewer.reviewer.id + }; + }); + + return ret.sort(loginComparator); +} + +/** + * Used for case insensitive sort by login + */ +export function loginComparator(a: IAccount, b: IAccount) { + // sensitivity: 'accent' allows case insensitive comparison + return a.login.localeCompare(b.login, 'en', { sensitivity: 'accent' }); +} +/** + * Used for case insensitive sort by team name + */ +export function teamComparator(a: ITeam, b: ITeam) { + const aKey = a.name ?? a.slug; + const bKey = b.name ?? b.slug; + // sensitivity: 'accent' allows case insensitive comparison + return aKey.localeCompare(bKey, 'en', { sensitivity: 'accent' }); +} + +export function parseGraphQLReviewEvent( + review: GraphQL.SubmittedReview, + githubRepository: GitHubRepository, +): Common.ReviewEvent { + return { + event: Common.EventType.Reviewed, + comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, githubRepository)).filter(c => !c.inReplyToId), + submittedAt: review.submittedAt, + body: review.body, + bodyHTML: review.bodyHTML, + htmlUrl: review.url, + user: parseAuthor(review.author, githubRepository), + authorAssociation: review.authorAssociation, + state: review.state, + id: review.databaseId, + }; +} + +export function parseGraphQLTimelineEvents( + events: ( + | GraphQL.MergedEvent + | GraphQL.Review + | GraphQL.IssueComment + | GraphQL.Commit + | GraphQL.AssignedEvent + | GraphQL.HeadRefDeletedEvent + )[], + githubRepository: GitHubRepository, +): Common.TimelineEvent[] { + const normalizedEvents: Common.TimelineEvent[] = []; + events.forEach(event => { + const type = convertGraphQLEventType(event.__typename); + + switch (type) { + case Common.EventType.Commented: + const commentEvent = event as GraphQL.IssueComment; + normalizedEvents.push({ + htmlUrl: commentEvent.url, + body: commentEvent.body, + bodyHTML: commentEvent.bodyHTML, + user: parseAuthor(commentEvent.author, githubRepository), + event: type, + canEdit: commentEvent.viewerCanUpdate, + canDelete: commentEvent.viewerCanDelete, + id: commentEvent.databaseId, + graphNodeId: commentEvent.id, + createdAt: commentEvent.createdAt, + }); + return; + case Common.EventType.Reviewed: + const reviewEvent = event as GraphQL.Review; + normalizedEvents.push({ + event: type, + comments: [], + submittedAt: reviewEvent.submittedAt, + body: reviewEvent.body, + bodyHTML: reviewEvent.bodyHTML, + htmlUrl: reviewEvent.url, + user: parseAuthor(reviewEvent.author, githubRepository), + authorAssociation: reviewEvent.authorAssociation, + state: reviewEvent.state, + id: reviewEvent.databaseId, + }); + return; + case Common.EventType.Committed: + const commitEv = event as GraphQL.Commit; + normalizedEvents.push({ + id: commitEv.id, + event: type, + sha: commitEv.commit.oid, + author: commitEv.commit.author.user + ? parseAuthor(commitEv.commit.author.user, githubRepository) + : { login: commitEv.commit.committer.name }, + htmlUrl: commitEv.url, + message: commitEv.commit.message, + authoredDate: new Date(commitEv.commit.authoredDate), + } as Common.CommitEvent); // TODO remove cast + return; + case Common.EventType.Merged: + const mergeEv = event as GraphQL.MergedEvent; + + normalizedEvents.push({ + id: mergeEv.id, + event: type, + user: parseActor(mergeEv.actor, githubRepository), + createdAt: mergeEv.createdAt, + mergeRef: mergeEv.mergeRef.name, + sha: mergeEv.commit.oid, + commitUrl: mergeEv.commit.commitUrl, + url: mergeEv.url, + graphNodeId: mergeEv.id, + }); + return; + case Common.EventType.Assigned: + const assignEv = event as GraphQL.AssignedEvent; + + normalizedEvents.push({ + id: assignEv.id, + event: type, + user: parseAuthor(assignEv.user, githubRepository), + actor: assignEv.actor, + }); + return; + case Common.EventType.HeadRefDeleted: + const deletedEv = event as GraphQL.HeadRefDeletedEvent; + + normalizedEvents.push({ + id: deletedEv.id, + event: type, + actor: parseActor(deletedEv.actor, githubRepository), + createdAt: deletedEv.createdAt, + headRef: deletedEv.headRefName, + }); + return; + default: + break; + } + }); + + return normalizedEvents; +} + +export function parseGraphQLUser(user: GraphQL.UserResponse, githubRepository: GitHubRepository): User { + return { + login: user.user.login, + name: user.user.name, + avatarUrl: getAvatarWithEnterpriseFallback(user.user.avatarUrl ?? '', undefined, githubRepository.remote.isEnterprise), + url: user.user.url, + bio: user.user.bio, + company: user.user.company, + location: user.user.location, + commitContributions: parseGraphQLCommitContributions(user.user.contributionsCollection), + id: user.user.id + }; +} + +function parseGraphQLCommitContributions( + commitComments: GraphQL.ContributionsCollection, +): { createdAt: Date; repoNameWithOwner: string }[] { + const items: { createdAt: Date; repoNameWithOwner: string }[] = []; + commitComments.commitContributionsByRepository.forEach(repoCommits => { + repoCommits.contributions.nodes.forEach(commit => { + items.push({ + createdAt: new Date(commit.occurredAt), + repoNameWithOwner: repoCommits.repository.nameWithOwner, + }); + }); + }); + return items; +} + +export function getReactionGroup(): { title: string; label: string; icon?: vscode.Uri }[] { + const ret = [ + { + title: 'THUMBS_UP', + // allow-any-unicode-next-line + label: '👍', + icon: Resource.icons.reactions.THUMBS_UP, + }, + { + title: 'THUMBS_DOWN', + // allow-any-unicode-next-line + label: '👎', + icon: Resource.icons.reactions.THUMBS_DOWN, + }, + { + title: 'LAUGH', + // allow-any-unicode-next-line + label: '😄', + icon: Resource.icons.reactions.LAUGH, + }, + { + title: 'HOORAY', + // allow-any-unicode-next-line + label: '🎉', + icon: Resource.icons.reactions.HOORAY, + }, + { + title: 'CONFUSED', + // allow-any-unicode-next-line + label: '😕', + icon: Resource.icons.reactions.CONFUSED, + }, + { + title: 'HEART', + // allow-any-unicode-next-line + label: '❤️', + icon: Resource.icons.reactions.HEART, + }, + { + title: 'ROCKET', + // allow-any-unicode-next-line + label: '🚀', + icon: Resource.icons.reactions.ROCKET, + }, + { + title: 'EYES', + // allow-any-unicode-next-line + label: '👀', + icon: Resource.icons.reactions.EYES, + }, + ]; + + return ret; +} + +export async function restPaginate(request: R, variables: Parameters[0]): Promise { + let page = 1; + let results: T[] = []; + let hasNextPage = false; + + do { + const result = await request( + { + ...(variables as any), + per_page: 100, + page + } + ); + + results = results.concat( + result.data as T[] + ); + + hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + page += 1; + } while (hasNextPage); + + return results; +} + +export function getRelatedUsersFromTimelineEvents( + timelineEvents: Common.TimelineEvent[], +): { login: string; name: string }[] { + const ret: { login: string; name: string }[] = []; + + timelineEvents.forEach(event => { + if (event.event === Common.EventType.Committed) { + ret.push({ + login: event.author.login, + name: event.author.name || '', + }); + } + + if (event.event === Common.EventType.Reviewed) { + ret.push({ + login: event.user.login, + name: event.user.name ?? event.user.login, + }); + } + + if (event.event === Common.EventType.Commented) { + ret.push({ + login: event.user.login, + name: event.user.name ?? event.user.login, + }); + } + }); + + return ret; +} + +export function parseGraphQLViewerPermission( + viewerPermissionResponse: GraphQL.ViewerPermissionResponse, +): ViewerPermission { + if (viewerPermissionResponse && viewerPermissionResponse.repository?.viewerPermission) { + if ( + (Object.values(ViewerPermission) as string[]).includes(viewerPermissionResponse.repository.viewerPermission) + ) { + return viewerPermissionResponse.repository.viewerPermission as ViewerPermission; + } + } + return ViewerPermission.Unknown; +} + +export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean { + return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || + (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && + file.path.substring(repository.rootUri.path.length).startsWith('/')); +} + +export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repository | undefined { + const foundRepos: Repository[] = []; + for (const repository of gitAPI.repositories.reverse()) { + if (isFileInRepo(repository, file)) { + foundRepos.push(repository); + } + } + if (foundRepos.length > 0) { + foundRepos.sort((a, b) => b.rootUri.path.length - a.rootUri.path.length); + return foundRepos[0]; + } + return undefined; +} + +/** + * Create a list of reviewers composed of people who have already left reviews on the PR, and + * those that have had a review requested of them. If a reviewer has left multiple reviews, the + * state should be the state of their most recent review, or 'REQUESTED' if they have an outstanding + * review request. + * @param requestedReviewers The list of reviewers that are requested for this pull request + * @param timelineEvents All timeline events for the pull request + * @param author The author of the pull request + */ +export function parseReviewers( + requestedReviewers: (IAccount | ITeam)[], + timelineEvents: Common.TimelineEvent[], + author: IAccount, +): ReviewState[] { + const reviewEvents = timelineEvents.filter((e): e is Common.ReviewEvent => e.event === Common.EventType.Reviewed).filter(event => event.state !== 'PENDING'); + let reviewers: ReviewState[] = []; + const seen = new Map(); + + // Do not show the author in the reviewer list + seen.set(author.login, true); + + for (let i = reviewEvents.length - 1; i >= 0; i--) { + const reviewer = reviewEvents[i].user; + if (!seen.get(reviewer.login)) { + seen.set(reviewer.login, true); + reviewers.push({ + reviewer: reviewer, + state: reviewEvents[i].state, + }); + } + } + + requestedReviewers.forEach(request => { + if (!seen.get(reviewerId(request))) { + reviewers.push({ + reviewer: request, + state: 'REQUESTED', + }); + } else { + const reviewer = reviewers.find(r => reviewerId(r.reviewer) === reviewerId(request)); + reviewer!.state = 'REQUESTED'; + } + }); + + // Put completed reviews before review requests and alphabetize each section + reviewers = reviewers.sort((a, b) => { + if (a.state === 'REQUESTED' && b.state !== 'REQUESTED') { + return 1; + } + + if (b.state === 'REQUESTED' && a.state !== 'REQUESTED') { + return -1; + } + + return reviewerLabel(a.reviewer).toLowerCase() < reviewerLabel(b.reviewer).toLowerCase() ? -1 : 1; + }); + + return reviewers; +} + +export function insertNewCommitsSinceReview( + timelineEvents: Common.TimelineEvent[], + latestReviewCommitOid: string | undefined, + currentUser: string, + head: GitHubRef | null +) { + if (latestReviewCommitOid && head && head.sha !== latestReviewCommitOid) { + let lastViewerReviewIndex: number = timelineEvents.length - 1; + let comittedDuringReview: boolean = false; + let interReviewCommits: Common.TimelineEvent[] = []; + + for (let i = timelineEvents.length - 1; i > 0; i--) { + if ( + timelineEvents[i].event === Common.EventType.Committed && + (timelineEvents[i] as Common.CommitEvent).sha === latestReviewCommitOid + ) { + interReviewCommits.unshift({ + id: latestReviewCommitOid, + event: Common.EventType.NewCommitsSinceReview + }); + timelineEvents.splice(lastViewerReviewIndex + 1, 0, ...interReviewCommits); + break; + } + else if (comittedDuringReview && timelineEvents[i].event === Common.EventType.Committed) { + interReviewCommits.unshift(timelineEvents[i]); + timelineEvents.splice(i, 1); + } + else if ( + !comittedDuringReview && + timelineEvents[i].event === Common.EventType.Reviewed && + (timelineEvents[i] as Common.ReviewEvent).user.login === currentUser + ) { + lastViewerReviewIndex = i; + comittedDuringReview = true; + } + } + } +} + +export function getPRFetchQuery(repo: string, user: string, query: string): string { + const filter = query.replace(/\$\{user\}/g, user); + return `is:pull-request ${filter} type:pr repo:${repo}`; +} + +export function isInCodespaces(): boolean { + return vscode.env.remoteName === 'codespaces' && vscode.env.uiKind === vscode.UIKind.Web; +} + +export async function setEnterpriseUri(host: string) { + return vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).update(URI, host, vscode.ConfigurationTarget.Workspace); +} + +export function getEnterpriseUri(): vscode.Uri | undefined { + const config: string = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); + if (config) { + let uri = vscode.Uri.parse(config, true); + if (uri.scheme === 'http') { + uri = uri.with({ scheme: 'https' }); + } + return uri; + } +} + +export function hasEnterpriseUri(): boolean { + return !!getEnterpriseUri(); +} + +export function generateGravatarUrl(gravatarId: string | undefined, size: number = 200): string | undefined { + return !!gravatarId ? `https://www.gravatar.com/avatar/${gravatarId}?s=${size}&d=retro` : undefined; +} + +export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, isEnterpriseRemote: boolean): string | undefined { + return !isEnterpriseRemote ? avatarUrl : (email ? generateGravatarUrl( + crypto.createHash('md5').update(email?.trim()?.toLowerCase()).digest('hex')) : undefined); +} + +export function getPullsUrl(repo: GitHubRepository) { + return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/pulls`); +} + +export function getIssuesUrl(repo: GitHubRepository) { + return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/issues`); +} + +export function sanitizeIssueTitle(title: string): string { + const regex = /[~^:;'".,~#?%*&[\]@\\{}()/]|\/\//g; + + return title.replace(regex, '').trim().substring(0, 150).replace(/\s+/g, '-'); +} + +const VARIABLE_PATTERN = /\$\{(.*?)\}/g; +export async function variableSubstitution( + value: string, + issueModel?: IssueModel, + defaults?: PullRequestDefaults, + user?: string, +): Promise { + return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { + switch (variable) { + case 'user': + return user ? user : match; + case 'issueNumber': + return issueModel ? `${issueModel.number}` : match; + case 'issueNumberLabel': + return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; + case 'issueTitle': + return issueModel ? issueModel.title : match; + case 'repository': + return defaults ? defaults.repo : match; + case 'owner': + return defaults ? defaults.owner : match; + case 'sanitizedIssueTitle': + return issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted + case 'sanitizedLowercaseIssueTitle': + return issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; + default: + return match; + } + }); +} + +export function getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { + const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; + if ( + repo && + (repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase() || + repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()) + ) { + parsedIssue.owner = issue.remote.owner; + parsedIssue.name = issue.remote.repositoryName; + } + return getIssueNumberLabelFromParsed(parsedIssue); +} + +export function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { + if (!parsed.owner || !parsed.name) { + return `#${parsed.issueNumber}`; + } else { + return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; + } +} + +export function getOverrideBranch(): string | undefined { + const overrideSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(OVERRIDE_DEFAULT_BRANCH); + if (overrideSetting) { + Logger.debug('Using override setting for default branch', GitHubRepository.ID); + return overrideSetting; + } +} + +export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepositoryManager[]): Promise<{ dotComRemotes: Remote[], enterpriseRemotes: Remote[], unknownRemotes: Remote[] }> { + // Check if we have found any github.com remotes + const dotComRemotes: Remote[] = []; + const enterpriseRemotes: Remote[] = []; + const unknownRemotes: Remote[] = []; + for (const manager of folderManagers) { + for (const remote of await manager.computeAllGitHubRemotes()) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComRemotes.push(remote); + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseRemotes.push(remote); + } + } + unknownRemotes.push(...await manager.computeAllUnknownRemotes()); + } + return { dotComRemotes, enterpriseRemotes, unknownRemotes }; +} + +export function vscodeDevPrLink(pullRequest: PullRequestModel) { + const itemUri = vscode.Uri.parse(pullRequest.html_url); + return `https://${vscode.env.appName.toLowerCase().includes('insider') ? 'insiders.' : ''}vscode.dev/github${itemUri.path}`; +} diff --git a/src/github/views.ts b/src/github/views.ts index 373f1fd365..64d553a1c3 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { TimelineEvent } from '../common/timelineEvent'; import { GithubItemStateEnum, @@ -92,4 +93,4 @@ export interface PullRequest { export interface ProjectItemsReply { projectItems: IProjectItem[] | undefined; -} \ No newline at end of file +} diff --git a/src/integrations/gitlens/gitlens.d.ts b/src/integrations/gitlens/gitlens.d.ts index 531a68d1c0..5e68378627 100644 --- a/src/integrations/gitlens/gitlens.d.ts +++ b/src/integrations/gitlens/gitlens.d.ts @@ -1,65 +1,66 @@ -'use strict'; -import { Disposable } from 'vscode'; - -export { Disposable } from 'vscode'; - -export interface RemoteProvider { - readonly id: string; - readonly name: string; - readonly domain: string; -} - -export interface CreatePullRequestActionContext { - readonly type: 'createPullRequest'; - - readonly repoPath: string; - readonly branch: { - readonly name: string; - readonly upstream: string | undefined; - readonly isRemote: boolean; - }; - readonly remote: - | { - readonly name: string; - readonly provider?: RemoteProvider; - readonly url?: string; - } - | undefined; -} - -export interface OpenPullRequestActionContext { - readonly type: 'openPullRequest'; - - readonly repoPath: string; - readonly provider: RemoteProvider | undefined; - readonly pullRequest: { - readonly id: string; - readonly url: string; - }; -} - -export type ActionContext = CreatePullRequestActionContext | OpenPullRequestActionContext; -export type Action = T['type']; - -export interface ActionRunner { - /* - * A unique key to identify the extension/product/company to which the runner belongs - */ - readonly partnerId: string; - - /* - * A user-friendly name to which the runner belongs, i.e. your extension/product/company name. Will be shown, less prominently, to the user when offering this action - */ - readonly name: string; - - /* - * A user-friendly string which describes the action that will be taken. Will be shown to the user when offering this action - */ - readonly label: string | ((context: ActionContext) => string); - - run(context: ActionContext): void | Promise; -} - -export interface GitLensApi { - registerActionRunner(action: Action, runner: ActionRunner): Disposable; -} +'use strict'; +import { Disposable } from 'vscode'; + +export { Disposable } from 'vscode'; + + +export interface RemoteProvider { + readonly id: string; + readonly name: string; + readonly domain: string; +} + +export interface CreatePullRequestActionContext { + readonly type: 'createPullRequest'; + + readonly repoPath: string; + readonly branch: { + readonly name: string; + readonly upstream: string | undefined; + readonly isRemote: boolean; + }; + readonly remote: + | { + readonly name: string; + readonly provider?: RemoteProvider; + readonly url?: string; + } + | undefined; +} + +export interface OpenPullRequestActionContext { + readonly type: 'openPullRequest'; + + readonly repoPath: string; + readonly provider: RemoteProvider | undefined; + readonly pullRequest: { + readonly id: string; + readonly url: string; + }; +} + +export type ActionContext = CreatePullRequestActionContext | OpenPullRequestActionContext; +export type Action = T['type']; + +export interface ActionRunner { + /* + * A unique key to identify the extension/product/company to which the runner belongs + */ + readonly partnerId: string; + + /* + * A user-friendly name to which the runner belongs, i.e. your extension/product/company name. Will be shown, less prominently, to the user when offering this action + */ + readonly name: string; + + /* + * A user-friendly string which describes the action that will be taken. Will be shown to the user when offering this action + */ + readonly label: string | ((context: ActionContext) => string); + + run(context: ActionContext): void | Promise; +} + +export interface GitLensApi { + registerActionRunner(action: Action, runner: ActionRunner): Disposable; +} diff --git a/src/integrations/gitlens/gitlensImpl.ts b/src/integrations/gitlens/gitlensImpl.ts index bb25b0f739..03c008fc30 100644 --- a/src/integrations/gitlens/gitlensImpl.ts +++ b/src/integrations/gitlens/gitlensImpl.ts @@ -1,75 +1,76 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { commands, Disposable, extensions } from 'vscode'; -import { CreatePullRequestActionContext, GitLensApi } from './gitlens'; - -export class GitLensIntegration implements Disposable { - private _extensionsDisposable: Disposable; - private _subscriptions: Disposable[] = []; - - constructor() { - this._extensionsDisposable = extensions.onDidChange(this.onExtensionsChanged, this); - this.onExtensionsChanged(); - } - - dispose() { - this._extensionsDisposable.dispose(); - Disposable.from(...this._subscriptions).dispose(); - } - - private register(api: GitLensApi | undefined) { - if (!api) { - return; - } - this._subscriptions.push( - api.registerActionRunner('createPullRequest', { - partnerId: 'ghpr', - name: 'GitHub Pull Requests and Issues', - label: 'Create Pull Request', - run: function (context: CreatePullRequestActionContext) { - // For now only work with branches that aren't remote - if (context.branch.isRemote) { - return; - } - - commands.executeCommand('pr.create', { - repoPath: context.repoPath, - compareBranch: context.branch.name, - }); - }, - }), - ); - } - - private async onExtensionsChanged() { - const extension = - extensions.getExtension>('eamodio.gitlens') ?? - extensions.getExtension>('eamodio.gitlens-insiders'); - if (extension) { - this._extensionsDisposable.dispose(); - - if (extension.isActive) { - this.register(await extension.exports); - } else { - let count = 0; - // https://github.com/microsoft/vscode/issues/113783 -- since no event exists, poll - const handle = setInterval(async () => { - if (extension.isActive) { - clearInterval(handle); - - this.register(await extension.exports); - } else { - count++; - // Give up after 60 seconds - if (count > 60) { - clearInterval(handle); - } - } - }, 1000); - } - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { commands, Disposable, extensions } from 'vscode'; +import { CreatePullRequestActionContext, GitLensApi } from './gitlens'; + +export class GitLensIntegration implements Disposable { + private _extensionsDisposable: Disposable; + private _subscriptions: Disposable[] = []; + + constructor() { + this._extensionsDisposable = extensions.onDidChange(this.onExtensionsChanged, this); + this.onExtensionsChanged(); + } + + dispose() { + this._extensionsDisposable.dispose(); + Disposable.from(...this._subscriptions).dispose(); + } + + private register(api: GitLensApi | undefined) { + if (!api) { + return; + } + this._subscriptions.push( + api.registerActionRunner('createPullRequest', { + partnerId: 'ghpr', + name: 'GitHub Pull Requests and Issues', + label: 'Create Pull Request', + run: function (context: CreatePullRequestActionContext) { + // For now only work with branches that aren't remote + if (context.branch.isRemote) { + return; + } + + commands.executeCommand('pr.create', { + repoPath: context.repoPath, + compareBranch: context.branch.name, + }); + }, + }), + ); + } + + private async onExtensionsChanged() { + const extension = + extensions.getExtension>('eamodio.gitlens') ?? + extensions.getExtension>('eamodio.gitlens-insiders'); + if (extension) { + this._extensionsDisposable.dispose(); + + if (extension.isActive) { + this.register(await extension.exports); + } else { + let count = 0; + // https://github.com/microsoft/vscode/issues/113783 -- since no event exists, poll + const handle = setInterval(async () => { + if (extension.isActive) { + clearInterval(handle); + + this.register(await extension.exports); + } else { + count++; + // Give up after 60 seconds + if (count > 60) { + clearInterval(handle); + } + } + }, 1000); + } + } + } +} diff --git a/src/issues/currentIssue.ts b/src/issues/currentIssue.ts index 7ec94e7bc1..fc74f10190 100644 --- a/src/issues/currentIssue.ts +++ b/src/issues/currentIssue.ts @@ -1,276 +1,277 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Branch, Repository } from '../api/api'; -import { GitErrorCodes } from '../api/api1'; -import { Remote } from '../common/remote'; -import { - ASSIGN_WHEN_WORKING, - ISSUE_BRANCH_TITLE, - ISSUES_SETTINGS_NAMESPACE, - USE_BRANCH_FOR_ISSUES, - WORKING_ISSUE_FORMAT_SCM, -} from '../common/settingKeys'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { GithubItemStateEnum } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { variableSubstitution } from '../github/utils'; -import { IssueState, StateManager } from './stateManager'; - -export class CurrentIssue { - private repoChangeDisposable: vscode.Disposable | undefined; - private _branchName: string | undefined; - private user: string | undefined; - private repo: Repository | undefined; - private _repoDefaults: PullRequestDefaults | undefined; - private _onDidChangeCurrentIssueState: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidChangeCurrentIssueState: vscode.Event = this._onDidChangeCurrentIssueState.event; - constructor( - private issueModel: IssueModel, - public readonly manager: FolderRepositoryManager, - private stateManager: StateManager, - remote?: Remote, - private shouldPromptForBranch?: boolean, - ) { - this.setRepo(remote ?? this.issueModel.githubRepository.remote); - } - - private setRepo(repoRemote: Remote) { - for (let i = 0; i < this.stateManager.gitAPI.repositories.length; i++) { - const repo = this.stateManager.gitAPI.repositories[i]; - for (let j = 0; j < repo.state.remotes.length; j++) { - const remote = repo.state.remotes[j]; - if ( - remote.name === repoRemote?.remoteName && - remote.fetchUrl - ?.toLowerCase() - .search(`${repoRemote.owner.toLowerCase()}/${repoRemote.repositoryName.toLowerCase()}`) !== -1 - ) { - this.repo = repo; - return; - } - } - } - } - - get branchName(): string | undefined { - return this._branchName; - } - - get repoDefaults(): PullRequestDefaults | undefined { - return this._repoDefaults; - } - - get issue(): IssueModel { - return this.issueModel; - } - - public async startWorking(silent: boolean = false): Promise { - try { - this._repoDefaults = await this.manager.getPullRequestDefaults(); - if (await this.createIssueBranch(silent)) { - await this.setCommitMessageAndGitEvent(); - this._onDidChangeCurrentIssueState.fire(); - const login = (await this.manager.getCurrentUser(this.issueModel.githubRepository)).login; - if ( - vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ASSIGN_WHEN_WORKING) && - !this.issueModel.assignees?.find(value => value.login === login) - ) { - // Check that we have a repo open for this issue and only try to assign in that case. - if (this.manager.gitHubRepositories.find( - r => r.remote.owner === this.issueModel.remote.owner && r.remote.repositoryName === this.issueModel.remote.repositoryName, - )) { - await this.manager.assignIssue(this.issueModel, login); - } - await this.stateManager.refresh(); - } - return true; - } - } catch (e) { - // leave repoDefaults undefined - vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t start working on an issue.')); - } - return false; - } - - public dispose() { - this.repoChangeDisposable?.dispose(); - } - - public async stopWorking(checkoutDefaultBranch: boolean) { - if (this.repo) { - this.repo.inputBox.value = ''; - } - if (this._repoDefaults && checkoutDefaultBranch) { - try { - await this.manager.repository.checkout(this._repoDefaults.base); - } catch (e) { - if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - vscode.window.showErrorMessage( - vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), - ); - } - throw e; - } - } - this._onDidChangeCurrentIssueState.fire(); - this.dispose(); - } - - private getBasicBranchName(user: string): string { - return `${user}/issue${this.issueModel.number}`; - } - - private async getBranch(branch: string): Promise { - try { - return await this.manager.repository.getBranch(branch); - } catch (e) { - // branch doesn't exist - } - return undefined; - } - - private async createOrCheckoutBranch(branch: string): Promise { - try { - if (await this.getBranch(branch)) { - await this.manager.repository.checkout(branch); - } else { - await this.manager.repository.createBranch(branch, true); - } - return true; - } catch (e) { - if (e.message !== 'User aborted') { - vscode.window.showErrorMessage( - `Unable to checkout branch ${branch}. There may be file conflicts that prevent this branch change. Git error: ${e.error}`, - ); - } - return false; - } - } - - private async getUser(): Promise { - if (!this.user) { - this.user = await this.issueModel.githubRepository.getAuthenticatedUser(); - } - return this.user; - } - - private async getBranchTitle(): Promise { - return ( - vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ISSUE_BRANCH_TITLE) ?? - this.getBasicBranchName(await this.getUser()) - ); - } - - private validateBranchName(branch: string): string | undefined { - const VALID_BRANCH_CHARACTERS = /[^ \\@\~\^\?\*\[]+/; - const match = branch.match(VALID_BRANCH_CHARACTERS); - if (match && match.length > 0 && match[0] !== branch) { - return vscode.l10n.t('Branch name cannot contain a space or the following characters: \\@~^?*['); - } - return undefined; - } - - private showBranchNameError(error: string) { - const editSetting = `Edit Setting`; - vscode.window.showErrorMessage(error, editSetting).then(result => { - if (result === editSetting) { - vscode.commands.executeCommand( - 'workbench.action.openSettings', - `${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_BRANCH_TITLE}`, - ); - } - }); - } - - private async offerNewBranch(branch: Branch, branchNameConfig: string, branchNameMatch: RegExpMatchArray | null | undefined): Promise { - // Check if this branch has a merged PR associated with it. - // If so, offer to create a new branch. - const pr = await this.manager.getMatchingPullRequestMetadataFromGitHub(branch, branch.upstream?.remote, branch.upstream?.name); - if (pr && (pr.model.state !== GithubItemStateEnum.Open)) { - const mergedMessage = vscode.l10n.t('The pull request for {0} has been merged. Do you want to create a new branch?', branch.name ?? 'unknown branch'); - const closedMessage = vscode.l10n.t('The pull request for {0} has been closed. Do you want to create a new branch?', branch.name ?? 'unknown branch'); - const createBranch = vscode.l10n.t('Create New Branch'); - const createNew = await vscode.window.showInformationMessage(pr.model.state === GithubItemStateEnum.Merged ? mergedMessage : closedMessage, - { - modal: true - }, createBranch); - if (createNew === createBranch) { - const number = (branchNameMatch?.length === 4 ? (Number(branchNameMatch[3]) + 1) : 1); - return `${branchNameConfig}_${number}`; - } - } - return branchNameConfig; - } - - private async createIssueBranch(silent: boolean): Promise { - const createBranchConfig = this.shouldPromptForBranch - ? 'prompt' - : vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); - if (createBranchConfig === 'off') { - return true; - } - const state: IssueState = this.stateManager.getSavedIssueState(this.issueModel.number); - this._branchName = this.shouldPromptForBranch ? undefined : state.branch; - const branchNameConfig = await variableSubstitution( - await this.getBranchTitle(), - this.issue, - undefined, - await this.getUser(), - ); - const branchNameMatch = this._branchName?.match(new RegExp('^(' + branchNameConfig + ')(_)?(\\d*)')); - if ((createBranchConfig === 'on')) { - const branch = await this.getBranch(this._branchName!); - if (!branch) { - if (!branchNameMatch) { - this._branchName = branchNameConfig; - } - } else if (!silent) { - this._branchName = await this.offerNewBranch(branch, branchNameConfig, branchNameMatch); - } - } - if (!this._branchName) { - this._branchName = await vscode.window.showInputBox({ - value: branchNameConfig, - prompt: vscode.l10n.t('Enter the label for the new branch.'), - }); - } - if (!this._branchName) { - // user has cancelled - return false; - } - - const validateBranchName = this.validateBranchName(this._branchName); - if (validateBranchName) { - this.showBranchNameError(validateBranchName); - return false; - } - - state.branch = this._branchName; - await this.stateManager.setSavedIssueState(this.issueModel, state); - if (!(await this.createOrCheckoutBranch(this._branchName))) { - this._branchName = undefined; - return false; - } - return true; - } - - public async getCommitMessage(): Promise { - const configuration = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(WORKING_ISSUE_FORMAT_SCM); - if (typeof configuration === 'string') { - return variableSubstitution(configuration, this.issueModel, this._repoDefaults); - } - return undefined; - } - - private async setCommitMessageAndGitEvent() { - const message = await this.getCommitMessage(); - if (this.repo && message) { - this.repo.inputBox.value = message; - } - return; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Branch, Repository } from '../api/api'; +import { GitErrorCodes } from '../api/api1'; +import { Remote } from '../common/remote'; +import { + ASSIGN_WHEN_WORKING, + ISSUE_BRANCH_TITLE, + ISSUES_SETTINGS_NAMESPACE, + USE_BRANCH_FOR_ISSUES, + WORKING_ISSUE_FORMAT_SCM, +} from '../common/settingKeys'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { GithubItemStateEnum } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { variableSubstitution } from '../github/utils'; +import { IssueState, StateManager } from './stateManager'; + +export class CurrentIssue { + private repoChangeDisposable: vscode.Disposable | undefined; + private _branchName: string | undefined; + private user: string | undefined; + private repo: Repository | undefined; + private _repoDefaults: PullRequestDefaults | undefined; + private _onDidChangeCurrentIssueState: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeCurrentIssueState: vscode.Event = this._onDidChangeCurrentIssueState.event; + constructor( + private issueModel: IssueModel, + public readonly manager: FolderRepositoryManager, + private stateManager: StateManager, + remote?: Remote, + private shouldPromptForBranch?: boolean, + ) { + this.setRepo(remote ?? this.issueModel.githubRepository.remote); + } + + private setRepo(repoRemote: Remote) { + for (let i = 0; i < this.stateManager.gitAPI.repositories.length; i++) { + const repo = this.stateManager.gitAPI.repositories[i]; + for (let j = 0; j < repo.state.remotes.length; j++) { + const remote = repo.state.remotes[j]; + if ( + remote.name === repoRemote?.remoteName && + remote.fetchUrl + ?.toLowerCase() + .search(`${repoRemote.owner.toLowerCase()}/${repoRemote.repositoryName.toLowerCase()}`) !== -1 + ) { + this.repo = repo; + return; + } + } + } + } + + get branchName(): string | undefined { + return this._branchName; + } + + get repoDefaults(): PullRequestDefaults | undefined { + return this._repoDefaults; + } + + get issue(): IssueModel { + return this.issueModel; + } + + public async startWorking(silent: boolean = false): Promise { + try { + this._repoDefaults = await this.manager.getPullRequestDefaults(); + if (await this.createIssueBranch(silent)) { + await this.setCommitMessageAndGitEvent(); + this._onDidChangeCurrentIssueState.fire(); + const login = (await this.manager.getCurrentUser(this.issueModel.githubRepository)).login; + if ( + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ASSIGN_WHEN_WORKING) && + !this.issueModel.assignees?.find(value => value.login === login) + ) { + // Check that we have a repo open for this issue and only try to assign in that case. + if (this.manager.gitHubRepositories.find( + r => r.remote.owner === this.issueModel.remote.owner && r.remote.repositoryName === this.issueModel.remote.repositoryName, + )) { + await this.manager.assignIssue(this.issueModel, login); + } + await this.stateManager.refresh(); + } + return true; + } + } catch (e) { + // leave repoDefaults undefined + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t start working on an issue.')); + } + return false; + } + + public dispose() { + this.repoChangeDisposable?.dispose(); + } + + public async stopWorking(checkoutDefaultBranch: boolean) { + if (this.repo) { + this.repo.inputBox.value = ''; + } + if (this._repoDefaults && checkoutDefaultBranch) { + try { + await this.manager.repository.checkout(this._repoDefaults.base); + } catch (e) { + if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + vscode.window.showErrorMessage( + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), + ); + } + throw e; + } + } + this._onDidChangeCurrentIssueState.fire(); + this.dispose(); + } + + private getBasicBranchName(user: string): string { + return `${user}/issue${this.issueModel.number}`; + } + + private async getBranch(branch: string): Promise { + try { + return await this.manager.repository.getBranch(branch); + } catch (e) { + // branch doesn't exist + } + return undefined; + } + + private async createOrCheckoutBranch(branch: string): Promise { + try { + if (await this.getBranch(branch)) { + await this.manager.repository.checkout(branch); + } else { + await this.manager.repository.createBranch(branch, true); + } + return true; + } catch (e) { + if (e.message !== 'User aborted') { + vscode.window.showErrorMessage( + `Unable to checkout branch ${branch}. There may be file conflicts that prevent this branch change. Git error: ${e.error}`, + ); + } + return false; + } + } + + private async getUser(): Promise { + if (!this.user) { + this.user = await this.issueModel.githubRepository.getAuthenticatedUser(); + } + return this.user; + } + + private async getBranchTitle(): Promise { + return ( + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ISSUE_BRANCH_TITLE) ?? + this.getBasicBranchName(await this.getUser()) + ); + } + + private validateBranchName(branch: string): string | undefined { + const VALID_BRANCH_CHARACTERS = /[^ \\@\~\^\?\*\[]+/; + const match = branch.match(VALID_BRANCH_CHARACTERS); + if (match && match.length > 0 && match[0] !== branch) { + return vscode.l10n.t('Branch name cannot contain a space or the following characters: \\@~^?*['); + } + return undefined; + } + + private showBranchNameError(error: string) { + const editSetting = `Edit Setting`; + vscode.window.showErrorMessage(error, editSetting).then(result => { + if (result === editSetting) { + vscode.commands.executeCommand( + 'workbench.action.openSettings', + `${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_BRANCH_TITLE}`, + ); + } + }); + } + + private async offerNewBranch(branch: Branch, branchNameConfig: string, branchNameMatch: RegExpMatchArray | null | undefined): Promise { + // Check if this branch has a merged PR associated with it. + // If so, offer to create a new branch. + const pr = await this.manager.getMatchingPullRequestMetadataFromGitHub(branch, branch.upstream?.remote, branch.upstream?.name); + if (pr && (pr.model.state !== GithubItemStateEnum.Open)) { + const mergedMessage = vscode.l10n.t('The pull request for {0} has been merged. Do you want to create a new branch?', branch.name ?? 'unknown branch'); + const closedMessage = vscode.l10n.t('The pull request for {0} has been closed. Do you want to create a new branch?', branch.name ?? 'unknown branch'); + const createBranch = vscode.l10n.t('Create New Branch'); + const createNew = await vscode.window.showInformationMessage(pr.model.state === GithubItemStateEnum.Merged ? mergedMessage : closedMessage, + { + modal: true + }, createBranch); + if (createNew === createBranch) { + const number = (branchNameMatch?.length === 4 ? (Number(branchNameMatch[3]) + 1) : 1); + return `${branchNameConfig}_${number}`; + } + } + return branchNameConfig; + } + + private async createIssueBranch(silent: boolean): Promise { + const createBranchConfig = this.shouldPromptForBranch + ? 'prompt' + : vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); + if (createBranchConfig === 'off') { + return true; + } + const state: IssueState = this.stateManager.getSavedIssueState(this.issueModel.number); + this._branchName = this.shouldPromptForBranch ? undefined : state.branch; + const branchNameConfig = await variableSubstitution( + await this.getBranchTitle(), + this.issue, + undefined, + await this.getUser(), + ); + const branchNameMatch = this._branchName?.match(new RegExp('^(' + branchNameConfig + ')(_)?(\\d*)')); + if ((createBranchConfig === 'on')) { + const branch = await this.getBranch(this._branchName!); + if (!branch) { + if (!branchNameMatch) { + this._branchName = branchNameConfig; + } + } else if (!silent) { + this._branchName = await this.offerNewBranch(branch, branchNameConfig, branchNameMatch); + } + } + if (!this._branchName) { + this._branchName = await vscode.window.showInputBox({ + value: branchNameConfig, + prompt: vscode.l10n.t('Enter the label for the new branch.'), + }); + } + if (!this._branchName) { + // user has cancelled + return false; + } + + const validateBranchName = this.validateBranchName(this._branchName); + if (validateBranchName) { + this.showBranchNameError(validateBranchName); + return false; + } + + state.branch = this._branchName; + await this.stateManager.setSavedIssueState(this.issueModel, state); + if (!(await this.createOrCheckoutBranch(this._branchName))) { + this._branchName = undefined; + return false; + } + return true; + } + + public async getCommitMessage(): Promise { + const configuration = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(WORKING_ISSUE_FORMAT_SCM); + if (typeof configuration === 'string') { + return variableSubstitution(configuration, this.issueModel, this._repoDefaults); + } + return undefined; + } + + private async setCommitMessageAndGitEvent() { + const message = await this.getCommitMessage(); + if (this.repo && message) { + this.repo.inputBox.value = message; + } + return; + } +} diff --git a/src/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index c1685c351f..ea1e7f58d9 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -1,252 +1,253 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { - IGNORE_COMPLETION_TRIGGER, - ISSUE_COMPLETION_FORMAT_SCM, - ISSUES_SETTINGS_NAMESPACE, -} from '../common/settingKeys'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IMilestone } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { MilestoneModel } from '../github/milestoneModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; -import { StateManager } from './stateManager'; -import { - getRootUriFromScmInputUri, - isComment, - issueMarkdown, -} from './util'; - -class IssueCompletionItem extends vscode.CompletionItem { - constructor(public readonly issue: IssueModel) { - super(`${issue.number}: ${issue.title}`, vscode.CompletionItemKind.Issue); - } -} - -export class IssueCompletionProvider implements vscode.CompletionItemProvider { - constructor( - private stateManager: StateManager, - private repositoriesManager: RepositoriesManager, - private context: vscode.ExtensionContext, - ) { } - - async provideCompletionItems( - document: vscode.TextDocument, - position: vscode.Position, - token: vscode.CancellationToken, - context: vscode.CompletionContext, - ): Promise { - let wordRange = document.getWordRangeAtPosition(position); - let wordAtPos = wordRange ? document.getText(wordRange) : undefined; - if (!wordRange || wordAtPos?.charAt(0) !== '#') { - const start = wordRange?.start ?? position; - const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); - const testWord = document.getText(testWordRange); - if (testWord.charAt(0) === '#') { - wordRange = testWordRange; - wordAtPos = testWord; - } - } - - // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character - if ( - document.languageId !== 'scminput' && - document.uri.scheme !== 'comment' && - position.character > 0 && - context.triggerKind === vscode.CompletionTriggerKind.Invoke && - !wordAtPos?.match(/#[0-9]*$/) - ) { - return []; - } - // It's common in markdown to start a line with #s and not want an completion - if ( - position.character <= 6 && - document.languageId === 'markdown' && - (document.getText(new vscode.Range(position.with(undefined, 0), position)) === - new Array(position.character + 1).join('#')) && - document.uri.scheme !== 'comment' && - context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter - ) { - return []; - } - - if ( - context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && - vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(IGNORE_COMPLETION_TRIGGER, []) - .find(value => value === document.languageId) - ) { - return []; - } - - if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { - return []; - } - - let range: vscode.Range = new vscode.Range(position, position); - if (position.character - 1 >= 0) { - if (wordRange && ((wordAtPos?.charAt(0) === '#') || (document.languageId === 'scminput') || (document.languageId === 'git-commit'))) { - range = wordRange; - } - } - - // Check for owner/repo preceding the # - let filterOwnerAndRepo: { owner: string; repo: string } | undefined; - if (wordAtPos === '#' && wordRange) { - if (wordRange.start.character >= 3) { - const ownerRepoRange = new vscode.Range( - wordRange.start.with(undefined, 0), - wordRange.start - ); - const ownerRepo = document.getText(ownerRepoRange); - const ownerRepoMatch = ownerRepo.match(/([^\s]+)\/([^\s]+)/); - if (ownerRepoMatch) { - filterOwnerAndRepo = { - owner: ownerRepoMatch[1], - repo: ownerRepoMatch[2], - }; - } - } - } - - const completionItems: Map = new Map(); - const now = new Date(); - let repo: PullRequestDefaults | undefined; - let uri: vscode.Uri | undefined; - if (document.languageId === 'scminput') { - uri = getRootUriFromScmInputUri(document.uri); - } else if ((document.uri.scheme === 'comment') && vscode.workspace.workspaceFolders?.length) { - for (const visibleEditor of vscode.window.visibleTextEditors) { - const testFolderUri = vscode.workspace.workspaceFolders[0].uri.with({ path: visibleEditor.document.uri.path }); - const workspace = vscode.workspace.getWorkspaceFolder(testFolderUri); - if (workspace) { - uri = workspace.uri; - break; - } - } - } else { - uri = document.uri.scheme === NEW_ISSUE_SCHEME - ? extractIssueOriginFromQuery(document.uri) ?? document.uri - : document.uri; - } - if (!uri) { - return []; - } - - let folderManager: FolderRepositoryManager | undefined; - try { - folderManager = this.repositoriesManager.getManagerForFile(uri); - repo = await folderManager?.getPullRequestDefaults(); - } catch (e) { - // leave repo undefined - } - const issueData = this.stateManager.getIssueCollection(folderManager?.repository.rootUri ?? uri); - - // Count up total number of issues. The number of queries is expected to be small. - let totalIssues = 0; - for (const issueQuery of issueData) { - const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; - if (issuesOrMilestones[0] instanceof IssueModel) { - totalIssues += issuesOrMilestones.length; - } else { - for (const milestone of issuesOrMilestones) { - totalIssues += (milestone as MilestoneModel).issues.length; - } - } - } - - for (const issueQuery of issueData) { - const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; - if (issuesOrMilestones.length === 0) { - continue; - } - if (issuesOrMilestones[0] instanceof IssueModel) { - let index = 0; - for (const issue of issuesOrMilestones) { - if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { - continue; - } - completionItems.set( - getIssueNumberLabel(issue as IssueModel), - await this.completionItemFromIssue(repo, issue as IssueModel, now, range, document, index++, totalIssues), - ); - } - } else { - for (let index = 0; index < issuesOrMilestones.length; index++) { - const value: MilestoneModel = issuesOrMilestones[index] as MilestoneModel; - for (const issue of value.issues) { - if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { - continue; - } - completionItems.set( - getIssueNumberLabel(issue), - await this.completionItemFromIssue( - repo, - issue, - now, - range, - document, - index, - totalIssues, - value.milestone, - ), - ); - } - } - } - } - return [...completionItems.values()]; - } - - private async completionItemFromIssue( - repo: PullRequestDefaults | undefined, - issue: IssueModel, - now: Date, - range: vscode.Range, - document: vscode.TextDocument, - index: number, - totalCount: number, - milestone?: IMilestone, - ): Promise { - const item: IssueCompletionItem = new IssueCompletionItem(issue); - if (document.languageId === 'markdown') { - item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; - } else { - const configuration = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(ISSUE_COMPLETION_FORMAT_SCM); - if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') { - item.insertText = await variableSubstitution(configuration, issue, repo); - } else { - item.insertText = `${getIssueNumberLabel(issue, repo)}`; - } - } - item.documentation = issue.body; - item.range = range; - item.detail = milestone ? milestone.title : issue.milestone?.title; - item.sortText = `${index}`.padStart(`${totalCount}`.length, '0'); - item.filterText = `${item.detail} # ${issue.number} ${issue.title} ${item.documentation}`; - return item; - } - - async resolveCompletionItem( - item: vscode.CompletionItem, - _token: vscode.CancellationToken, - ): Promise { - if (item instanceof IssueCompletionItem) { - item.documentation = await issueMarkdown(item.issue, this.context, this.repositoriesManager); - item.command = { - command: 'issues.issueCompletion', - title: vscode.l10n.t('Issue Completion Choose,'), - }; - } - return item; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { + IGNORE_COMPLETION_TRIGGER, + ISSUE_COMPLETION_FORMAT_SCM, + ISSUES_SETTINGS_NAMESPACE, +} from '../common/settingKeys'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IMilestone } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { MilestoneModel } from '../github/milestoneModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; +import { extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; +import { StateManager } from './stateManager'; +import { + getRootUriFromScmInputUri, + isComment, + issueMarkdown, +} from './util'; + +class IssueCompletionItem extends vscode.CompletionItem { + constructor(public readonly issue: IssueModel) { + super(`${issue.number}: ${issue.title}`, vscode.CompletionItemKind.Issue); + } +} + +export class IssueCompletionProvider implements vscode.CompletionItemProvider { + constructor( + private stateManager: StateManager, + private repositoriesManager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext, + ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '#') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '#') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } + + // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character + if ( + document.languageId !== 'scminput' && + document.uri.scheme !== 'comment' && + position.character > 0 && + context.triggerKind === vscode.CompletionTriggerKind.Invoke && + !wordAtPos?.match(/#[0-9]*$/) + ) { + return []; + } + // It's common in markdown to start a line with #s and not want an completion + if ( + position.character <= 6 && + document.languageId === 'markdown' && + (document.getText(new vscode.Range(position.with(undefined, 0), position)) === + new Array(position.character + 1).join('#')) && + document.uri.scheme !== 'comment' && + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter + ) { + return []; + } + + if ( + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && + vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_COMPLETION_TRIGGER, []) + .find(value => value === document.languageId) + ) { + return []; + } + + if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + return []; + } + + let range: vscode.Range = new vscode.Range(position, position); + if (position.character - 1 >= 0) { + if (wordRange && ((wordAtPos?.charAt(0) === '#') || (document.languageId === 'scminput') || (document.languageId === 'git-commit'))) { + range = wordRange; + } + } + + // Check for owner/repo preceding the # + let filterOwnerAndRepo: { owner: string; repo: string } | undefined; + if (wordAtPos === '#' && wordRange) { + if (wordRange.start.character >= 3) { + const ownerRepoRange = new vscode.Range( + wordRange.start.with(undefined, 0), + wordRange.start + ); + const ownerRepo = document.getText(ownerRepoRange); + const ownerRepoMatch = ownerRepo.match(/([^\s]+)\/([^\s]+)/); + if (ownerRepoMatch) { + filterOwnerAndRepo = { + owner: ownerRepoMatch[1], + repo: ownerRepoMatch[2], + }; + } + } + } + + const completionItems: Map = new Map(); + const now = new Date(); + let repo: PullRequestDefaults | undefined; + let uri: vscode.Uri | undefined; + if (document.languageId === 'scminput') { + uri = getRootUriFromScmInputUri(document.uri); + } else if ((document.uri.scheme === 'comment') && vscode.workspace.workspaceFolders?.length) { + for (const visibleEditor of vscode.window.visibleTextEditors) { + const testFolderUri = vscode.workspace.workspaceFolders[0].uri.with({ path: visibleEditor.document.uri.path }); + const workspace = vscode.workspace.getWorkspaceFolder(testFolderUri); + if (workspace) { + uri = workspace.uri; + break; + } + } + } else { + uri = document.uri.scheme === NEW_ISSUE_SCHEME + ? extractIssueOriginFromQuery(document.uri) ?? document.uri + : document.uri; + } + if (!uri) { + return []; + } + + let folderManager: FolderRepositoryManager | undefined; + try { + folderManager = this.repositoriesManager.getManagerForFile(uri); + repo = await folderManager?.getPullRequestDefaults(); + } catch (e) { + // leave repo undefined + } + const issueData = this.stateManager.getIssueCollection(folderManager?.repository.rootUri ?? uri); + + // Count up total number of issues. The number of queries is expected to be small. + let totalIssues = 0; + for (const issueQuery of issueData) { + const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; + if (issuesOrMilestones[0] instanceof IssueModel) { + totalIssues += issuesOrMilestones.length; + } else { + for (const milestone of issuesOrMilestones) { + totalIssues += (milestone as MilestoneModel).issues.length; + } + } + } + + for (const issueQuery of issueData) { + const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; + if (issuesOrMilestones.length === 0) { + continue; + } + if (issuesOrMilestones[0] instanceof IssueModel) { + let index = 0; + for (const issue of issuesOrMilestones) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; + } + completionItems.set( + getIssueNumberLabel(issue as IssueModel), + await this.completionItemFromIssue(repo, issue as IssueModel, now, range, document, index++, totalIssues), + ); + } + } else { + for (let index = 0; index < issuesOrMilestones.length; index++) { + const value: MilestoneModel = issuesOrMilestones[index] as MilestoneModel; + for (const issue of value.issues) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; + } + completionItems.set( + getIssueNumberLabel(issue), + await this.completionItemFromIssue( + repo, + issue, + now, + range, + document, + index, + totalIssues, + value.milestone, + ), + ); + } + } + } + } + return [...completionItems.values()]; + } + + private async completionItemFromIssue( + repo: PullRequestDefaults | undefined, + issue: IssueModel, + now: Date, + range: vscode.Range, + document: vscode.TextDocument, + index: number, + totalCount: number, + milestone?: IMilestone, + ): Promise { + const item: IssueCompletionItem = new IssueCompletionItem(issue); + if (document.languageId === 'markdown') { + item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; + } else { + const configuration = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(ISSUE_COMPLETION_FORMAT_SCM); + if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') { + item.insertText = await variableSubstitution(configuration, issue, repo); + } else { + item.insertText = `${getIssueNumberLabel(issue, repo)}`; + } + } + item.documentation = issue.body; + item.range = range; + item.detail = milestone ? milestone.title : issue.milestone?.title; + item.sortText = `${index}`.padStart(`${totalCount}`.length, '0'); + item.filterText = `${item.detail} # ${issue.number} ${issue.title} ${item.documentation}`; + return item; + } + + async resolveCompletionItem( + item: vscode.CompletionItem, + _token: vscode.CancellationToken, + ): Promise { + if (item instanceof IssueCompletionItem) { + item.documentation = await issueMarkdown(item.issue, this.context, this.repositoriesManager); + item.command = { + command: 'issues.issueCompletion', + title: vscode.l10n.t('Issue Completion Choose,'), + }; + } + return item; + } +} diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 2c728264ee..7f96bba524 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -1,1314 +1,1315 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { GitApiImpl } from '../api/api1'; -import Logger from '../common/logger'; -import { - CREATE_INSERT_FORMAT, - ENABLED, - ISSUE_COMPLETIONS, - ISSUES_SETTINGS_NAMESPACE, - QUERIES, - USER_COMPLETIONS, -} from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { OctokitCommon } from '../github/common'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; -import { ReviewManager } from '../view/reviewManager'; -import { ReviewsManager } from '../view/reviewsManager'; -import { CurrentIssue } from './currentIssue'; -import { IssueCompletionProvider } from './issueCompletionProvider'; -import { - ASSIGNEES, - extractMetadataFromFile, - IssueFileSystemProvider, - LABELS, - MILESTONE, - NEW_ISSUE_FILE, - NEW_ISSUE_SCHEME, - NewIssueCache, - NewIssueFileCompletionProvider, -} from './issueFile'; -import { IssueHoverProvider } from './issueHoverProvider'; -import { openCodeLink } from './issueLinkLookup'; -import { IssuesTreeData, IssueUriTreeItem, updateExpandedQueries } from './issuesView'; -import { IssueTodoProvider } from './issueTodoProvider'; -import { ShareProviderManager } from './shareProviders'; -import { StateManager } from './stateManager'; -import { UserCompletionProvider } from './userCompletionProvider'; -import { UserHoverProvider } from './userHoverProvider'; -import { - createGitHubLink, - createGithubPermalink, - getIssue, - IssueTemplate, - LinkContext, - NewIssue, - PERMALINK_COMPONENT, - PermalinkInfo, - pushAndCreatePR, - USER_EXPRESSION, -} from './util'; - -const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; - -export class IssueFeatureRegistrar implements vscode.Disposable { - private _stateManager: StateManager; - private _newIssueCache: NewIssueCache; - - private createIssueInfo: - | { - document: vscode.TextDocument; - newIssue: NewIssue | undefined; - lineNumber: number | undefined; - insertIndex: number | undefined; - } - | undefined; - - constructor( - private gitAPI: GitApiImpl, - private manager: RepositoriesManager, - private reviewsManager: ReviewsManager, - private context: vscode.ExtensionContext, - private telemetry: ITelemetry, - ) { - this._stateManager = new StateManager(gitAPI, this.manager, this.context); - this._newIssueCache = new NewIssueCache(context); - } - - async initialize() { - this.context.subscriptions.push( - vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider(this._newIssueCache)), - ); - this.context.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - { scheme: NEW_ISSUE_SCHEME }, - new NewIssueFileCompletionProvider(this.manager), - ' ', - ',', - ), - ); - const view = vscode.window.createTreeView('issues:github', { - showCollapseAll: true, - treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), - }); - this.context.subscriptions.push(view); - this.context.subscriptions.push(view.onDidCollapseElement(e => updateExpandedQueries(this.context, e.element, false))); - this.context.subscriptions.push(view.onDidExpandElement(e => updateExpandedQueries(this.context, e.element, true))); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromSelection', - (newIssue?: NewIssue, issueBody?: string) => { - /* __GDPR__ - "issue.createIssueFromSelection" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromSelection'); - return this.createTodoIssue(newIssue, issueBody); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromClipboard', - () => { - /* __GDPR__ - "issue.createIssueFromClipboard" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromClipboard'); - return this.createTodoIssueClipboard(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubPermalink', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); - return this.copyPermalink(this.manager, context); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubHeadLink', - (fileUri: any) => { - /* __GDPR__ - "issue.copyGithubHeadLink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLink'); - return this.copyHeadLink(fileUri); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubPermalinkWithoutRange', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyGithubPermalinkWithoutRange" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubPermalinkWithoutRange'); - return this.copyPermalink(this.manager, context, false); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubHeadLinkWithoutRange', - (fileUri: any) => { - /* __GDPR__ - "issue.copyGithubHeadLinkWithoutRange" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLinkWithoutRange'); - return this.copyHeadLink(fileUri, false); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubDevLinkWithoutRange', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyGithubDevLinkWithoutRange" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkWithoutRange'); - return this.copyPermalink(this.manager, context, false, true, true); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubDevLink', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyGithubDevLink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubDevLink'); - return this.copyPermalink(this.manager, context, true, true, true); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubDevLinkFile', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyGithubDevLinkFile" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkFile'); - return this.copyPermalink(this.manager, context, false, true, true); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyMarkdownGithubPermalink', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyMarkdownGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); - return this.copyMarkdownPermalink(this.manager, context); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyMarkdownGithubPermalinkWithoutRange', - (context: LinkContext) => { - /* __GDPR__ - "issue.copyMarkdownGithubPermalinkWithoutRange" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalinkWithoutRange'); - return this.copyMarkdownPermalink(this.manager, context, false); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.openGithubPermalink', - () => { - /* __GDPR__ - "issue.openGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.openGithubPermalink'); - return this.openPermalink(this.manager); - }, - this, - ), - ); - this.context.subscriptions.push(new ShareProviderManager(this.manager, this.gitAPI)); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { - /* __GDPR__ - "issue.openIssue" : {} - */ - this.telemetry.sendTelemetryEvent('issue.openIssue'); - return this.openIssue(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorking', - (issue: any) => { - /* __GDPR__ - "issue.startWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorkingBranchDescriptiveTitle', - (issue: any) => { - /* __GDPR__ - "issue.startWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.continueWorking', - (issue: any) => { - /* __GDPR__ - "issue.continueWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.continueWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorkingBranchPrompt', - (issueModel: any) => { - /* __GDPR__ - "issue.startWorkingBranchPrompt" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorkingBranchPrompt'); - return this.startWorkingBranchPrompt(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.stopWorking', - (issueModel: any) => { - /* __GDPR__ - "issue.stopWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.stopWorking'); - return this.stopWorking(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.stopWorkingBranchDescriptiveTitle', - (issueModel: any) => { - /* __GDPR__ - "issue.stopWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.stopWorking'); - return this.stopWorking(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.statusBar', - () => { - /* __GDPR__ - "issue.statusBar" : {} - */ - this.telemetry.sendTelemetryEvent('issue.statusBar'); - return this.statusBar(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.copyIssueNumber', (issueModel: any) => { - /* __GDPR__ - "issue.copyIssueNumber" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyIssueNumber'); - return this.copyIssueNumber(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.copyIssueUrl', (issueModel: any) => { - /* __GDPR__ - "issue.copyIssueUrl" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyIssueUrl'); - return this.copyIssueUrl(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.refresh', - () => { - /* __GDPR__ - "issue.refresh" : {} - */ - this.telemetry.sendTelemetryEvent('issue.refresh'); - return this.refreshView(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.suggestRefresh', - () => { - /* __GDPR__ - "issue.suggestRefresh" : {} - */ - this.telemetry.sendTelemetryEvent('issue.suggestRefresh'); - return this.suggestRefresh(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.getCurrent', - () => { - /* __GDPR__ - "issue.getCurrent" : {} - */ - this.telemetry.sendTelemetryEvent('issue.getCurrent'); - return this.getCurrent(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.editQuery', - (query: IssueUriTreeItem) => { - /* __GDPR__ - "issue.editQuery" : {} - */ - this.telemetry.sendTelemetryEvent('issue.editQuery'); - return this.editQuery(query); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssue', - () => { - /* __GDPR__ - "issue.createIssue" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssue'); - return this.createIssue(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromFile', - async () => { - /* __GDPR__ - "issue.createIssueFromFile" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromFile'); - await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, true); - await this.createIssueFromFile(); - await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, false); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.issueCompletion', () => { - /* __GDPR__ - "issue.issueCompletion" : {} - */ - this.telemetry.sendTelemetryEvent('issue.issueCompletion'); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.userCompletion', () => { - /* __GDPR__ - "issue.userCompletion" : {} - */ - this.telemetry.sendTelemetryEvent('issue.userCompletion'); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.signinAndRefreshList', async () => { - return this.manager.authenticate(); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.goToLinkedCode', async (issueModel: any) => { - return openCodeLink(issueModel, this.manager); - }), - ); - this._stateManager.tryInitializeAndWait().then(() => { - this.registerCompletionProviders(); - - this.context.subscriptions.push( - vscode.languages.registerHoverProvider( - '*', - new IssueHoverProvider(this.manager, this._stateManager, this.context, this.telemetry), - ), - ); - this.context.subscriptions.push( - vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), - ); - this.context.subscriptions.push( - vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), - ); - }); - } - - dispose() { } - - private documentFilters: Array = [ - { language: 'php' }, - { language: 'powershell' }, - { language: 'jade' }, - { language: 'python' }, - { language: 'r' }, - { language: 'razor' }, - { language: 'ruby' }, - { language: 'rust' }, - { language: 'scminput' }, - { language: 'scss' }, - { language: 'search-result' }, - { language: 'shaderlab' }, - { language: 'shellscript' }, - { language: 'sql' }, - { language: 'swift' }, - { language: 'typescript' }, - { language: 'vb' }, - { language: 'xml' }, - { language: 'yaml' }, - { language: 'markdown' }, - { language: 'bat' }, - { language: 'clojure' }, - { language: 'coffeescript' }, - { language: 'jsonc' }, - { language: 'c' }, - { language: 'cpp' }, - { language: 'csharp' }, - { language: 'css' }, - { language: 'dockerfile' }, - { language: 'fsharp' }, - { language: 'git-commit' }, - { language: 'go' }, - { language: 'groovy' }, - { language: 'handlebars' }, - { language: 'hlsl' }, - { language: 'html' }, - { language: 'ini' }, - { language: 'java' }, - { language: 'javascriptreact' }, - { language: 'javascript' }, - { language: 'json' }, - { language: 'less' }, - { language: 'log' }, - { language: 'lua' }, - { language: 'makefile' }, - { language: 'ignore' }, - { language: 'properties' }, - { language: 'objective-c' }, - { language: 'perl' }, - { language: 'perl6' }, - { language: 'typescriptreact' }, - { language: 'yml' }, - '*', - ]; - private registerCompletionProviders() { - const providers: { - provider: typeof IssueCompletionProvider | typeof UserCompletionProvider; - trigger: string; - disposable: vscode.Disposable | undefined; - configuration: string; - }[] = [ - { - provider: IssueCompletionProvider, - trigger: '#', - disposable: undefined, - configuration: `${ISSUE_COMPLETIONS}.${ENABLED}`, - }, - { - provider: UserCompletionProvider, - trigger: '@', - disposable: undefined, - configuration: `${USER_COMPLETIONS}.${ENABLED}`, - }, - ]; - for (const element of providers) { - if (vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(element.configuration, true)) { - this.context.subscriptions.push( - (element.disposable = vscode.languages.registerCompletionItemProvider( - this.documentFilters, - new element.provider(this._stateManager, this.manager, this.context), - element.trigger, - )), - ); - } - } - this.context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(change => { - for (const element of providers) { - if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${element.configuration}`)) { - const newValue: boolean = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(element.configuration, true); - if (!newValue && element.disposable) { - element.disposable.dispose(); - element.disposable = undefined; - } else if (newValue && !element.disposable) { - this.context.subscriptions.push( - (element.disposable = vscode.languages.registerCompletionItemProvider( - this.documentFilters, - new element.provider(this._stateManager, this.manager, this.context), - element.trigger, - )), - ); - } - break; - } - } - }), - ); - } - - async createIssue() { - let uri = vscode.window.activeTextEditor?.document.uri; - let folderManager: FolderRepositoryManager | undefined = uri ? this.manager.getManagerForFile(uri) : undefined; - if (!folderManager) { - folderManager = await this.chooseRepo(vscode.l10n.t('Select the repo to create the issue in.')); - uri = folderManager?.repository.rootUri; - } - if (!folderManager || !uri) { - return; - } - - const template = await this.chooseTemplate(folderManager); - this._newIssueCache.clear(); - if (template) { - this.makeNewIssueFile(uri, template.title, template.body); - } else { - this.makeNewIssueFile(uri); - } - } - - async createIssueFromFile() { - const metadata = await extractMetadataFromFile(this.manager); - if (!metadata || !vscode.window.activeTextEditor) { - return; - } - const createSucceeded = await this.doCreateIssue( - this.createIssueInfo?.document, - this.createIssueInfo?.newIssue, - metadata.title, - metadata.body, - metadata.assignees, - metadata.labels, - metadata.milestone, - this.createIssueInfo?.lineNumber, - this.createIssueInfo?.insertIndex, - metadata.originUri - ); - this.createIssueInfo = undefined; - if (createSucceeded) { - await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - this._newIssueCache.clear(); - } - } - - async editQuery(query: IssueUriTreeItem) { - const config = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE, null); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); - let command: string; - if (inspect?.workspaceValue) { - command = 'workbench.action.openWorkspaceSettingsFile'; - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES); - if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); - } - command = 'workbench.action.openSettingsJson'; - } - await vscode.commands.executeCommand(command); - const editor = vscode.window.activeTextEditor; - if (editor) { - const text = editor.document.getText(); - const search = text.search(query.labelAsString!); - if (search >= 0) { - const position = editor.document.positionAt(search); - editor.revealRange(new vscode.Range(position, position)); - editor.selection = new vscode.Selection(position, position); - } - } - } - - getCurrent() { - // This is used by the "api" command issues.getCurrent - const currentIssues = this._stateManager.currentIssues(); - if (currentIssues.length > 0) { - return { - owner: currentIssues[0].issue.remote.owner, - repo: currentIssues[0].issue.remote.repositoryName, - number: currentIssues[0].issue.number, - }; - } - return undefined; - } - - refreshView() { - this._stateManager.refreshCacheNeeded(); - } - - async suggestRefresh() { - await vscode.commands.executeCommand('hideSuggestWidget'); - await this._stateManager.refresh(); - return vscode.commands.executeCommand('editor.action.triggerSuggest'); - } - - openIssue(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.openExternal(vscode.Uri.parse(issueModel.html_url)); - } - return undefined; - } - - async doStartWorking( - matchingRepoManager: FolderRepositoryManager | undefined, - issueModel: IssueModel, - needsBranchPrompt?: boolean, - ) { - let repoManager = matchingRepoManager; - let githubRepository = issueModel.githubRepository; - let remote = issueModel.remote; - if (!repoManager) { - repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this isssue in.')); - if (!repoManager) { - return; - } - githubRepository = await repoManager.getOrigin(); - remote = githubRepository.remote; - } - - const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); - if (remoteNameResult.needsFork) { - if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { - return; - } - } - - await this._stateManager.setCurrentIssue( - repoManager, - new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt), - true - ); - } - - async startWorking(issue: any) { - if (issue instanceof IssueModel) { - return this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); - } else if (issue instanceof vscode.Uri) { - const match = issue.toString().match(ISSUE_OR_URL_EXPRESSION); - const parsed = parseIssueExpressionOutput(match); - const folderManager = this.manager.folderManagers.find(folderManager => - folderManager.gitHubRepositories.find(repo => repo.remote.owner === parsed?.owner && repo.remote.repositoryName === parsed.name)); - if (parsed && folderManager) { - const issueModel = await getIssue(this._stateManager, folderManager, issue.toString(), parsed); - if (issueModel) { - return this.doStartWorking(folderManager, issueModel); - } - } - } - } - - async startWorkingBranchPrompt(issueModel: any) { - if (!(issueModel instanceof IssueModel)) { - return; - } - this.doStartWorking(this.manager.getManagerForIssueModel(issueModel), issueModel, true); - } - - async stopWorking(issueModel: any) { - let folderManager = this.manager.getManagerForIssueModel(issueModel); - if (!folderManager) { - folderManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to stop working on this issue in.')); - if (!folderManager) { - return; - } - } - if ( - issueModel instanceof IssueModel && - this._stateManager.currentIssue(folderManager.repository.rootUri)?.issue.number === issueModel.number - ) { - await this._stateManager.setCurrentIssue(folderManager, undefined, true); - } - } - - private async statusBarActions(currentIssue: CurrentIssue) { - const openIssueText: string = vscode.l10n.t('{0} Open #{1} {2}', '$(globe)', currentIssue.issue.number, currentIssue.issue.title); - const pullRequestText: string = vscode.l10n.t({ message: '{0} Create pull request for #{1} (pushes branch)', args: ['$(git-pull-request)', currentIssue.issue.number], comment: ['The first placeholder is an icon and shouldn\'t be localized', 'The second placeholder is the ID number of a GitHub Issue.'] }); - let defaults: PullRequestDefaults | undefined; - try { - defaults = await currentIssue.manager.getPullRequestDefaults(); - } catch (e) { - // leave defaults undefined - } - const stopWorkingText: string = vscode.l10n.t('{0} Stop working on #{1}', '$(primitive-square)', currentIssue.issue.number); - const choices = - currentIssue.branchName && defaults - ? [openIssueText, pullRequestText, stopWorkingText] - : [openIssueText, pullRequestText, stopWorkingText]; - const response: string | undefined = await vscode.window.showQuickPick(choices, { - placeHolder: vscode.l10n.t('Current issue options'), - }); - switch (response) { - case openIssueText: - return this.openIssue(currentIssue.issue); - case pullRequestText: { - const reviewManager = ReviewManager.getReviewManagerForFolderManager( - this.reviewsManager.reviewManagers, - currentIssue.manager, - ); - if (reviewManager) { - return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager); - } - break; - } - case stopWorkingText: - return this._stateManager.setCurrentIssue(currentIssue.manager, undefined, true); - } - } - - async statusBar() { - const currentIssues = this._stateManager.currentIssues(); - if (currentIssues.length === 1) { - return this.statusBarActions(currentIssues[0]); - } else { - interface IssueChoice extends vscode.QuickPickItem { - currentIssue: CurrentIssue; - } - const choices: IssueChoice[] = currentIssues.map(currentIssue => { - return { - label: vscode.l10n.t('#{0} from {1}', currentIssue.issue.number, `${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`), - currentIssue, - }; - }); - const response: IssueChoice | undefined = await vscode.window.showQuickPick(choices); - if (response) { - return this.statusBarActions(response.currentIssue); - } - } - } - - private stringToUint8Array(input: string): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(input); - } - - copyIssueNumber(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.clipboard.writeText(issueModel.number.toString()); - } - return undefined; - } - - copyIssueUrl(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.clipboard.writeText(issueModel.html_url); - } - return undefined; - } - - async createTodoIssueClipboard() { - return this.createTodoIssue(undefined, await vscode.env.clipboard.readText()); - } - - private async createTodoIssueBody(newIssue?: NewIssue, issueBody?: string): Promise { - if (issueBody || newIssue?.document.isUntitled) { - return issueBody; - } - - let contents = ''; - if (newIssue) { - const repository = getRepositoryForFile(this.gitAPI, newIssue.document.uri); - const changeAffectingFile = repository?.state.workingTreeChanges.find(value => value.uri.toString() === newIssue.document.uri.toString()); - if (changeAffectingFile) { - // The file we're creating the issue for has uncommitted changes. - // Add a quote of the line so that the issue body is still meaningful. - contents = `\`\`\`\n${newIssue.line}\n\`\`\`\n\n`; - } - } - contents += (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; - return contents; - } - - async createTodoIssue(newIssue?: NewIssue, issueBody?: string) { - let document: vscode.TextDocument; - let titlePlaceholder: string | undefined; - let insertIndex: number | undefined; - let lineNumber: number | undefined; - let assignee: string[] | undefined; - let issueGenerationText: string | undefined; - if (!newIssue && vscode.window.activeTextEditor) { - document = vscode.window.activeTextEditor.document; - issueGenerationText = document.getText(vscode.window.activeTextEditor.selection); - } else if (newIssue) { - document = newIssue.document; - insertIndex = newIssue.insertIndex; - lineNumber = newIssue.lineNumber; - titlePlaceholder = newIssue.line.substring(insertIndex, newIssue.line.length).trim(); - issueGenerationText = document.getText( - newIssue.range.isEmpty ? document.lineAt(newIssue.range.start.line).range : newIssue.range, - ); - } else { - return undefined; - } - const matches = issueGenerationText.match(USER_EXPRESSION); - if (matches && matches.length === 2 && (await this._stateManager.getUserMap(document.uri)).has(matches[1])) { - assignee = [matches[1]]; - } - let title: string | undefined; - const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); - - const quickInput = vscode.window.createInputBox(); - quickInput.value = titlePlaceholder ?? ''; - quickInput.prompt = - vscode.l10n.t('Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'); - quickInput.title = vscode.l10n.t('Create Issue'); - quickInput.buttons = [ - { - iconPath: new vscode.ThemeIcon('edit'), - tooltip: vscode.l10n.t('Edit Description'), - }, - ]; - quickInput.onDidAccept(async () => { - title = quickInput.value; - if (title) { - quickInput.busy = true; - await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, undefined, lineNumber, insertIndex); - quickInput.busy = false; - } - quickInput.hide(); - }); - quickInput.onDidTriggerButton(async () => { - title = quickInput.value; - quickInput.busy = true; - this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; - - this.makeNewIssueFile(document.uri, title, body, assignee); - quickInput.busy = false; - quickInput.hide(); - }); - quickInput.show(); - - return undefined; - } - - private async makeNewIssueFile( - originUri: vscode.Uri, - title?: string, - body?: string, - assignees?: string[] | undefined, - ) { - const query = `?{"origin":"${originUri.toString()}"}`; - const bodyPath = vscode.Uri.parse(`${NEW_ISSUE_SCHEME}:/${NEW_ISSUE_FILE}${query}`); - if ( - vscode.window.visibleTextEditors.filter( - visibleEditor => visibleEditor.document.uri.scheme === NEW_ISSUE_SCHEME, - ).length > 0 - ) { - return; - } - await vscode.workspace.fs.delete(bodyPath); - const assigneeLine = `${ASSIGNEES} ${assignees && assignees.length > 0 ? assignees.map(value => '@' + value).join(', ') + ' ' : '' - }`; - const labelLine = `${LABELS} `; - const milestoneLine = `${MILESTONE} `; - const cached = this._newIssueCache.get(); - const text = (cached && cached !== '') ? cached : `${title ?? vscode.l10n.t('Issue Title')}\n -${assigneeLine} -${labelLine} -${milestoneLine}\n -${body ?? ''}\n -`; - await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); - const assigneesDecoration = vscode.window.createTextEditorDecorationType({ - after: { - contentText: vscode.l10n.t(' Comma-separated usernames, either @username or just username.'), - fontStyle: 'italic', - color: new vscode.ThemeColor('issues.newIssueDecoration'), - }, - }); - const labelsDecoration = vscode.window.createTextEditorDecorationType({ - after: { - contentText: vscode.l10n.t(' Comma-separated labels.'), - fontStyle: 'italic', - color: new vscode.ThemeColor('issues.newIssueDecoration'), - }, - }); - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(textEditor => { - if (textEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { - const assigneeFullLine = textEditor.document.lineAt(2); - if (assigneeFullLine.text.startsWith(ASSIGNEES)) { - textEditor.setDecorations(assigneesDecoration, [ - new vscode.Range( - new vscode.Position(2, 0), - new vscode.Position(2, assigneeFullLine.text.length), - ), - ]); - } - const labelFullLine = textEditor.document.lineAt(3); - if (labelFullLine.text.startsWith(LABELS)) { - textEditor.setDecorations(labelsDecoration, [ - new vscode.Range(new vscode.Position(3, 0), new vscode.Position(3, labelFullLine.text.length)), - ]); - } - } - }); - - const editor = await vscode.window.showTextDocument(bodyPath); - const closeDisposable = vscode.workspace.onDidCloseTextDocument(textDocument => { - if (textDocument === editor.document) { - editorChangeDisposable.dispose(); - closeDisposable.dispose(); - } - }); - } - - private async verifyLabels( - folderManager: FolderRepositoryManager, - createParams: OctokitCommon.IssuesCreateParams, - ): Promise { - if (!createParams.labels) { - return true; - } - const allLabels = (await folderManager.getLabels(undefined, createParams)).map(label => label.name); - const newLabels: string[] = []; - const filteredLabels: string[] = []; - createParams.labels?.forEach(paramLabel => { - let label = typeof paramLabel === 'string' ? paramLabel : paramLabel.name; - if (!label) { - return; - } - - if (allLabels.includes(label)) { - filteredLabels.push(label); - } else { - newLabels.push(label); - } - }); - - if (newLabels.length > 0) { - const yes = vscode.l10n.t('Yes'); - const no = vscode.l10n.t('No'); - const promptResult = await vscode.window.showInformationMessage( - vscode.l10n.t('The following labels don\'t exist in this repository: {0}. \nDo you want to create these labels?', newLabels.join( - ', ', - )), - { modal: true }, - yes, - no, - ); - switch (promptResult) { - case yes: - return true; - case no: { - createParams.labels = filteredLabels; - return true; - } - default: - return false; - } - } - return true; - } - - private async chooseRepo(prompt: string): Promise { - interface RepoChoice extends vscode.QuickPickItem { - repo: FolderRepositoryManager; - } - const choices: RepoChoice[] = []; - for (const folderManager of this.manager.folderManagers) { - try { - const defaults = await folderManager.getPullRequestDefaults(); - choices.push({ - label: `${defaults.owner}/${defaults.repo}`, - repo: folderManager, - }); - } catch (e) { - // ignore - } - } - if (choices.length === 0) { - return; - } else if (choices.length === 1) { - return choices[0].repo; - } - - const choice = await vscode.window.showQuickPick(choices, { placeHolder: prompt }); - return choice?.repo; - } - - private async chooseTemplate(folderManager: FolderRepositoryManager): Promise<{ title: string | undefined, body: string | undefined } | undefined> { - const templateUris = await folderManager.getIssueTemplates(); - if (templateUris.length === 0) { - return undefined; - } - - interface IssueChoice extends vscode.QuickPickItem { - template: IssueTemplate | undefined; - } - const templates = await Promise.all( - templateUris - .map(async uri => { - try { - const content = await vscode.workspace.fs.readFile(uri); - const text = new TextDecoder('utf-8').decode(content); - const template = this.getDataFromTemplate(text); - - return template; - } catch (e) { - Logger.warn(`Reading issue template failed: ${e}`); - return undefined; - } - }) - ); - const choices: IssueChoice[] = templates.filter(template => !!template && !!template?.name).map(template => { - return { - label: template!.name!, - description: template!.about, - template: template, - }; - }); - choices.push({ - label: vscode.l10n.t('Blank issue'), - template: undefined - }); - - const selectedTemplate = await vscode.window.showQuickPick(choices, { - placeHolder: vscode.l10n.t('Select a template for the new issue.'), - }); - - return selectedTemplate?.template; - } - - private getDataFromTemplate(template: string): IssueTemplate { - const title = template.match(/title:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); - const name = template.match(/name:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); - const about = template.match(/about:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); - const body = template.match(/---([\s\S]*)---([\s\S]*)/)?.[2]; - return { title, name, about, body }; - } - - private async doCreateIssue( - document: vscode.TextDocument | undefined, - newIssue: NewIssue | undefined, - title: string, - issueBody: string | undefined, - assignees: string[] | undefined, - labels: string[] | undefined, - milestone: number | undefined, - lineNumber: number | undefined, - insertIndex: number | undefined, - originUri?: vscode.Uri, - ): Promise { - let origin: PullRequestDefaults | undefined; - let folderManager: FolderRepositoryManager | undefined; - if (document) { - folderManager = this.manager.getManagerForFile(document.uri); - } else if (originUri) { - folderManager = this.manager.getManagerForFile(originUri); - } - if (!folderManager) { - folderManager = await this.chooseRepo(vscode.l10n.t('Choose where to create the issue.')); - } - - return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating issue') }, async (progress) => { - if (!folderManager) { - return false; - } - progress.report({ message: vscode.l10n.t('Verifying that issue data is valid...') }); - try { - origin = await folderManager.getPullRequestDefaults(); - } catch (e) { - // There is no remote - vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t create an issue.')); - return false; - } - const body: string | undefined = - issueBody || newIssue?.document.isUntitled - ? issueBody - : (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; - const createParams: OctokitCommon.IssuesCreateParams = { - owner: origin.owner, - repo: origin.repo, - title, - body, - assignees, - labels, - milestone - }; - if (!(await this.verifyLabels(folderManager, createParams))) { - return false; - } - progress.report({ message: vscode.l10n.t('Creating issue in {0}...', `${createParams.owner}/${createParams.repo}`) }); - const issue = await folderManager.createIssue(createParams); - if (issue) { - if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { - const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); - const insertText: string = - vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_INSERT_FORMAT, 'number') === - 'number' - ? `#${issue.number}` - : issue.html_url; - edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); - await vscode.workspace.applyEdit(edit); - } else { - const copyIssueUrl = vscode.l10n.t('Copy Issue Link'); - const openIssue = vscode.l10n.t({ message: 'Open Issue', comment: 'Open the issue description in the browser to see it\'s full contents.' }); - vscode.window.showInformationMessage(vscode.l10n.t('Issue created'), copyIssueUrl, openIssue).then(async result => { - switch (result) { - case copyIssueUrl: - await vscode.env.clipboard.writeText(issue.html_url); - break; - case openIssue: - await vscode.env.openExternal(vscode.Uri.parse(issue.html_url)); - break; - } - }); - } - this._stateManager.refreshCacheNeeded(); - return true; - } - return false; - }); - } - - private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext): Promise { - const link = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', link.error)); - } - return link; - } - - private async getHeadLinkWithError(context?: vscode.Uri, includeRange?: boolean): Promise { - const link = await createGitHubLink(this.manager, context, includeRange); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', link.error)); - } - return link; - } - - private async getContextualizedLink(file: vscode.Uri, link: string): Promise { - let uri: vscode.Uri; - try { - uri = await vscode.env.asExternalUri(file); - } catch (e) { - // asExternalUri can throw when in the browser and the embedder doesn't set a uri resolver. - return link; - } - const authority = (uri.scheme === 'https' && /^(insiders\.vscode|vscode|github)\./.test(uri.authority)) ? uri.authority : undefined; - if (!authority) { - return link; - } - const linkUri = vscode.Uri.parse(link); - const linkPath = /^(github)\./.test(uri.authority) ? linkUri.path : `/github${linkUri.path}`; - return linkUri.with({ authority, path: linkPath }).toString(); - } - - async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext, includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { - const link = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); - if (link.permalink) { - const contextualizedLink = contextualizeLink && link.originalFile ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink; - Logger.debug(`writing ${contextualizedLink} to the clipboard`, PERMALINK_COMPONENT); - return vscode.env.clipboard.writeText(contextualizedLink); - } - } - - async copyHeadLink(fileUri?: vscode.Uri, includeRange = true) { - const link = await this.getHeadLinkWithError(fileUri, includeRange); - if (link.permalink) { - return vscode.env.clipboard.writeText(link.permalink); - } - } - - private getMarkdownLinkText(): string | undefined { - if (!vscode.window.activeTextEditor) { - return undefined; - } - let editorSelection: vscode.Range | undefined = vscode.window.activeTextEditor.selection; - if (editorSelection.start.line !== editorSelection.end.line) { - editorSelection = new vscode.Range( - editorSelection.start, - new vscode.Position(editorSelection.start.line + 1, 0), - ); - } - const selection = vscode.window.activeTextEditor.document.getText(editorSelection); - if (selection) { - return selection; - } - editorSelection = vscode.window.activeTextEditor.document.getWordRangeAtPosition(editorSelection.start); - if (editorSelection) { - return vscode.window.activeTextEditor.document.getText(editorSelection); - } - return undefined; - } - - async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext, includeRange: boolean = true) { - const link = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); - const selection = this.getMarkdownLinkText(); - if (link.permalink && selection) { - return vscode.env.clipboard.writeText(`[${selection.trim()}](${link.permalink})`); - } - } - - async openPermalink(repositoriesManager: RepositoriesManager) { - const link = await this.getPermalinkWithError(repositoriesManager, true, true); - if (link.permalink) { - return vscode.env.openExternal(vscode.Uri.parse(link.permalink)); - } - return undefined; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { + CREATE_INSERT_FORMAT, + ENABLED, + ISSUE_COMPLETIONS, + ISSUES_SETTINGS_NAMESPACE, + QUERIES, + USER_COMPLETIONS, +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { OctokitCommon } from '../github/common'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IssueModel } from '../github/issueModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; +import { ReviewManager } from '../view/reviewManager'; +import { ReviewsManager } from '../view/reviewsManager'; +import { CurrentIssue } from './currentIssue'; +import { IssueCompletionProvider } from './issueCompletionProvider'; +import { + ASSIGNEES, + extractMetadataFromFile, + IssueFileSystemProvider, + LABELS, + MILESTONE, + NEW_ISSUE_FILE, + NEW_ISSUE_SCHEME, + NewIssueCache, + NewIssueFileCompletionProvider, +} from './issueFile'; +import { IssueHoverProvider } from './issueHoverProvider'; +import { openCodeLink } from './issueLinkLookup'; +import { IssuesTreeData, IssueUriTreeItem, updateExpandedQueries } from './issuesView'; +import { IssueTodoProvider } from './issueTodoProvider'; +import { ShareProviderManager } from './shareProviders'; +import { StateManager } from './stateManager'; +import { UserCompletionProvider } from './userCompletionProvider'; +import { UserHoverProvider } from './userHoverProvider'; +import { + createGitHubLink, + createGithubPermalink, + getIssue, + IssueTemplate, + LinkContext, + NewIssue, + PERMALINK_COMPONENT, + PermalinkInfo, + pushAndCreatePR, + USER_EXPRESSION, +} from './util'; + +const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; + +export class IssueFeatureRegistrar implements vscode.Disposable { + private _stateManager: StateManager; + private _newIssueCache: NewIssueCache; + + private createIssueInfo: + | { + document: vscode.TextDocument; + newIssue: NewIssue | undefined; + lineNumber: number | undefined; + insertIndex: number | undefined; + } + | undefined; + + constructor( + private gitAPI: GitApiImpl, + private manager: RepositoriesManager, + private reviewsManager: ReviewsManager, + private context: vscode.ExtensionContext, + private telemetry: ITelemetry, + ) { + this._stateManager = new StateManager(gitAPI, this.manager, this.context); + this._newIssueCache = new NewIssueCache(context); + } + + async initialize() { + this.context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider(this._newIssueCache)), + ); + this.context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { scheme: NEW_ISSUE_SCHEME }, + new NewIssueFileCompletionProvider(this.manager), + ' ', + ',', + ), + ); + const view = vscode.window.createTreeView('issues:github', { + showCollapseAll: true, + treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), + }); + this.context.subscriptions.push(view); + this.context.subscriptions.push(view.onDidCollapseElement(e => updateExpandedQueries(this.context, e.element, false))); + this.context.subscriptions.push(view.onDidExpandElement(e => updateExpandedQueries(this.context, e.element, true))); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.createIssueFromSelection', + (newIssue?: NewIssue, issueBody?: string) => { + /* __GDPR__ + "issue.createIssueFromSelection" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromSelection'); + return this.createTodoIssue(newIssue, issueBody); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.createIssueFromClipboard', + () => { + /* __GDPR__ + "issue.createIssueFromClipboard" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromClipboard'); + return this.createTodoIssueClipboard(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubPermalink', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); + return this.copyPermalink(this.manager, context); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubHeadLink', + (fileUri: any) => { + /* __GDPR__ + "issue.copyGithubHeadLink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLink'); + return this.copyHeadLink(fileUri); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubPermalinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubPermalinkWithoutRange'); + return this.copyPermalink(this.manager, context, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubHeadLinkWithoutRange', + (fileUri: any) => { + /* __GDPR__ + "issue.copyGithubHeadLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLinkWithoutRange'); + return this.copyHeadLink(fileUri, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkWithoutRange'); + return this.copyPermalink(this.manager, context, false, true, true); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLink', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLink'); + return this.copyPermalink(this.manager, context, true, true, true); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkFile', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLinkFile" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkFile'); + return this.copyPermalink(this.manager, context, false, true, true); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyMarkdownGithubPermalink', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyMarkdownGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); + return this.copyMarkdownPermalink(this.manager, context); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyMarkdownGithubPermalinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyMarkdownGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalinkWithoutRange'); + return this.copyMarkdownPermalink(this.manager, context, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.openGithubPermalink', + () => { + /* __GDPR__ + "issue.openGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.openGithubPermalink'); + return this.openPermalink(this.manager); + }, + this, + ), + ); + this.context.subscriptions.push(new ShareProviderManager(this.manager, this.gitAPI)); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { + /* __GDPR__ + "issue.openIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.openIssue'); + return this.openIssue(issueModel); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.startWorking', + (issue: any) => { + /* __GDPR__ + "issue.startWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.startWorkingBranchDescriptiveTitle', + (issue: any) => { + /* __GDPR__ + "issue.startWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.continueWorking', + (issue: any) => { + /* __GDPR__ + "issue.continueWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.continueWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.startWorkingBranchPrompt', + (issueModel: any) => { + /* __GDPR__ + "issue.startWorkingBranchPrompt" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorkingBranchPrompt'); + return this.startWorkingBranchPrompt(issueModel); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.stopWorking', + (issueModel: any) => { + /* __GDPR__ + "issue.stopWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.stopWorking'); + return this.stopWorking(issueModel); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.stopWorkingBranchDescriptiveTitle', + (issueModel: any) => { + /* __GDPR__ + "issue.stopWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.stopWorking'); + return this.stopWorking(issueModel); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.statusBar', + () => { + /* __GDPR__ + "issue.statusBar" : {} + */ + this.telemetry.sendTelemetryEvent('issue.statusBar'); + return this.statusBar(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.copyIssueNumber', (issueModel: any) => { + /* __GDPR__ + "issue.copyIssueNumber" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyIssueNumber'); + return this.copyIssueNumber(issueModel); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.copyIssueUrl', (issueModel: any) => { + /* __GDPR__ + "issue.copyIssueUrl" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyIssueUrl'); + return this.copyIssueUrl(issueModel); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.refresh', + () => { + /* __GDPR__ + "issue.refresh" : {} + */ + this.telemetry.sendTelemetryEvent('issue.refresh'); + return this.refreshView(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.suggestRefresh', + () => { + /* __GDPR__ + "issue.suggestRefresh" : {} + */ + this.telemetry.sendTelemetryEvent('issue.suggestRefresh'); + return this.suggestRefresh(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.getCurrent', + () => { + /* __GDPR__ + "issue.getCurrent" : {} + */ + this.telemetry.sendTelemetryEvent('issue.getCurrent'); + return this.getCurrent(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.editQuery', + (query: IssueUriTreeItem) => { + /* __GDPR__ + "issue.editQuery" : {} + */ + this.telemetry.sendTelemetryEvent('issue.editQuery'); + return this.editQuery(query); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.createIssue', + () => { + /* __GDPR__ + "issue.createIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssue'); + return this.createIssue(); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.createIssueFromFile', + async () => { + /* __GDPR__ + "issue.createIssueFromFile" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromFile'); + await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, true); + await this.createIssueFromFile(); + await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.issueCompletion', () => { + /* __GDPR__ + "issue.issueCompletion" : {} + */ + this.telemetry.sendTelemetryEvent('issue.issueCompletion'); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.userCompletion', () => { + /* __GDPR__ + "issue.userCompletion" : {} + */ + this.telemetry.sendTelemetryEvent('issue.userCompletion'); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.signinAndRefreshList', async () => { + return this.manager.authenticate(); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.goToLinkedCode', async (issueModel: any) => { + return openCodeLink(issueModel, this.manager); + }), + ); + this._stateManager.tryInitializeAndWait().then(() => { + this.registerCompletionProviders(); + + this.context.subscriptions.push( + vscode.languages.registerHoverProvider( + '*', + new IssueHoverProvider(this.manager, this._stateManager, this.context, this.telemetry), + ), + ); + this.context.subscriptions.push( + vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), + ); + this.context.subscriptions.push( + vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), + ); + }); + } + + dispose() { } + + private documentFilters: Array = [ + { language: 'php' }, + { language: 'powershell' }, + { language: 'jade' }, + { language: 'python' }, + { language: 'r' }, + { language: 'razor' }, + { language: 'ruby' }, + { language: 'rust' }, + { language: 'scminput' }, + { language: 'scss' }, + { language: 'search-result' }, + { language: 'shaderlab' }, + { language: 'shellscript' }, + { language: 'sql' }, + { language: 'swift' }, + { language: 'typescript' }, + { language: 'vb' }, + { language: 'xml' }, + { language: 'yaml' }, + { language: 'markdown' }, + { language: 'bat' }, + { language: 'clojure' }, + { language: 'coffeescript' }, + { language: 'jsonc' }, + { language: 'c' }, + { language: 'cpp' }, + { language: 'csharp' }, + { language: 'css' }, + { language: 'dockerfile' }, + { language: 'fsharp' }, + { language: 'git-commit' }, + { language: 'go' }, + { language: 'groovy' }, + { language: 'handlebars' }, + { language: 'hlsl' }, + { language: 'html' }, + { language: 'ini' }, + { language: 'java' }, + { language: 'javascriptreact' }, + { language: 'javascript' }, + { language: 'json' }, + { language: 'less' }, + { language: 'log' }, + { language: 'lua' }, + { language: 'makefile' }, + { language: 'ignore' }, + { language: 'properties' }, + { language: 'objective-c' }, + { language: 'perl' }, + { language: 'perl6' }, + { language: 'typescriptreact' }, + { language: 'yml' }, + '*', + ]; + private registerCompletionProviders() { + const providers: { + provider: typeof IssueCompletionProvider | typeof UserCompletionProvider; + trigger: string; + disposable: vscode.Disposable | undefined; + configuration: string; + }[] = [ + { + provider: IssueCompletionProvider, + trigger: '#', + disposable: undefined, + configuration: `${ISSUE_COMPLETIONS}.${ENABLED}`, + }, + { + provider: UserCompletionProvider, + trigger: '@', + disposable: undefined, + configuration: `${USER_COMPLETIONS}.${ENABLED}`, + }, + ]; + for (const element of providers) { + if (vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(element.configuration, true)) { + this.context.subscriptions.push( + (element.disposable = vscode.languages.registerCompletionItemProvider( + this.documentFilters, + new element.provider(this._stateManager, this.manager, this.context), + element.trigger, + )), + ); + } + } + this.context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(change => { + for (const element of providers) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${element.configuration}`)) { + const newValue: boolean = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(element.configuration, true); + if (!newValue && element.disposable) { + element.disposable.dispose(); + element.disposable = undefined; + } else if (newValue && !element.disposable) { + this.context.subscriptions.push( + (element.disposable = vscode.languages.registerCompletionItemProvider( + this.documentFilters, + new element.provider(this._stateManager, this.manager, this.context), + element.trigger, + )), + ); + } + break; + } + } + }), + ); + } + + async createIssue() { + let uri = vscode.window.activeTextEditor?.document.uri; + let folderManager: FolderRepositoryManager | undefined = uri ? this.manager.getManagerForFile(uri) : undefined; + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Select the repo to create the issue in.')); + uri = folderManager?.repository.rootUri; + } + if (!folderManager || !uri) { + return; + } + + const template = await this.chooseTemplate(folderManager); + this._newIssueCache.clear(); + if (template) { + this.makeNewIssueFile(uri, template.title, template.body); + } else { + this.makeNewIssueFile(uri); + } + } + + async createIssueFromFile() { + const metadata = await extractMetadataFromFile(this.manager); + if (!metadata || !vscode.window.activeTextEditor) { + return; + } + const createSucceeded = await this.doCreateIssue( + this.createIssueInfo?.document, + this.createIssueInfo?.newIssue, + metadata.title, + metadata.body, + metadata.assignees, + metadata.labels, + metadata.milestone, + this.createIssueInfo?.lineNumber, + this.createIssueInfo?.insertIndex, + metadata.originUri + ); + this.createIssueInfo = undefined; + if (createSucceeded) { + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + this._newIssueCache.clear(); + } + } + + async editQuery(query: IssueUriTreeItem) { + const config = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE, null); + const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); + let command: string; + if (inspect?.workspaceValue) { + command = 'workbench.action.openWorkspaceSettingsFile'; + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { + config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); + } + command = 'workbench.action.openSettingsJson'; + } + await vscode.commands.executeCommand(command); + const editor = vscode.window.activeTextEditor; + if (editor) { + const text = editor.document.getText(); + const search = text.search(query.labelAsString!); + if (search >= 0) { + const position = editor.document.positionAt(search); + editor.revealRange(new vscode.Range(position, position)); + editor.selection = new vscode.Selection(position, position); + } + } + } + + getCurrent() { + // This is used by the "api" command issues.getCurrent + const currentIssues = this._stateManager.currentIssues(); + if (currentIssues.length > 0) { + return { + owner: currentIssues[0].issue.remote.owner, + repo: currentIssues[0].issue.remote.repositoryName, + number: currentIssues[0].issue.number, + }; + } + return undefined; + } + + refreshView() { + this._stateManager.refreshCacheNeeded(); + } + + async suggestRefresh() { + await vscode.commands.executeCommand('hideSuggestWidget'); + await this._stateManager.refresh(); + return vscode.commands.executeCommand('editor.action.triggerSuggest'); + } + + openIssue(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.openExternal(vscode.Uri.parse(issueModel.html_url)); + } + return undefined; + } + + async doStartWorking( + matchingRepoManager: FolderRepositoryManager | undefined, + issueModel: IssueModel, + needsBranchPrompt?: boolean, + ) { + let repoManager = matchingRepoManager; + let githubRepository = issueModel.githubRepository; + let remote = issueModel.remote; + if (!repoManager) { + repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this isssue in.')); + if (!repoManager) { + return; + } + githubRepository = await repoManager.getOrigin(); + remote = githubRepository.remote; + } + + const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); + if (remoteNameResult.needsFork) { + if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { + return; + } + } + + await this._stateManager.setCurrentIssue( + repoManager, + new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt), + true + ); + } + + async startWorking(issue: any) { + if (issue instanceof IssueModel) { + return this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); + } else if (issue instanceof vscode.Uri) { + const match = issue.toString().match(ISSUE_OR_URL_EXPRESSION); + const parsed = parseIssueExpressionOutput(match); + const folderManager = this.manager.folderManagers.find(folderManager => + folderManager.gitHubRepositories.find(repo => repo.remote.owner === parsed?.owner && repo.remote.repositoryName === parsed.name)); + if (parsed && folderManager) { + const issueModel = await getIssue(this._stateManager, folderManager, issue.toString(), parsed); + if (issueModel) { + return this.doStartWorking(folderManager, issueModel); + } + } + } + } + + async startWorkingBranchPrompt(issueModel: any) { + if (!(issueModel instanceof IssueModel)) { + return; + } + this.doStartWorking(this.manager.getManagerForIssueModel(issueModel), issueModel, true); + } + + async stopWorking(issueModel: any) { + let folderManager = this.manager.getManagerForIssueModel(issueModel); + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to stop working on this issue in.')); + if (!folderManager) { + return; + } + } + if ( + issueModel instanceof IssueModel && + this._stateManager.currentIssue(folderManager.repository.rootUri)?.issue.number === issueModel.number + ) { + await this._stateManager.setCurrentIssue(folderManager, undefined, true); + } + } + + private async statusBarActions(currentIssue: CurrentIssue) { + const openIssueText: string = vscode.l10n.t('{0} Open #{1} {2}', '$(globe)', currentIssue.issue.number, currentIssue.issue.title); + const pullRequestText: string = vscode.l10n.t({ message: '{0} Create pull request for #{1} (pushes branch)', args: ['$(git-pull-request)', currentIssue.issue.number], comment: ['The first placeholder is an icon and shouldn\'t be localized', 'The second placeholder is the ID number of a GitHub Issue.'] }); + let defaults: PullRequestDefaults | undefined; + try { + defaults = await currentIssue.manager.getPullRequestDefaults(); + } catch (e) { + // leave defaults undefined + } + const stopWorkingText: string = vscode.l10n.t('{0} Stop working on #{1}', '$(primitive-square)', currentIssue.issue.number); + const choices = + currentIssue.branchName && defaults + ? [openIssueText, pullRequestText, stopWorkingText] + : [openIssueText, pullRequestText, stopWorkingText]; + const response: string | undefined = await vscode.window.showQuickPick(choices, { + placeHolder: vscode.l10n.t('Current issue options'), + }); + switch (response) { + case openIssueText: + return this.openIssue(currentIssue.issue); + case pullRequestText: { + const reviewManager = ReviewManager.getReviewManagerForFolderManager( + this.reviewsManager.reviewManagers, + currentIssue.manager, + ); + if (reviewManager) { + return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager); + } + break; + } + case stopWorkingText: + return this._stateManager.setCurrentIssue(currentIssue.manager, undefined, true); + } + } + + async statusBar() { + const currentIssues = this._stateManager.currentIssues(); + if (currentIssues.length === 1) { + return this.statusBarActions(currentIssues[0]); + } else { + interface IssueChoice extends vscode.QuickPickItem { + currentIssue: CurrentIssue; + } + const choices: IssueChoice[] = currentIssues.map(currentIssue => { + return { + label: vscode.l10n.t('#{0} from {1}', currentIssue.issue.number, `${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`), + currentIssue, + }; + }); + const response: IssueChoice | undefined = await vscode.window.showQuickPick(choices); + if (response) { + return this.statusBarActions(response.currentIssue); + } + } + } + + private stringToUint8Array(input: string): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(input); + } + + copyIssueNumber(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.clipboard.writeText(issueModel.number.toString()); + } + return undefined; + } + + copyIssueUrl(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.clipboard.writeText(issueModel.html_url); + } + return undefined; + } + + async createTodoIssueClipboard() { + return this.createTodoIssue(undefined, await vscode.env.clipboard.readText()); + } + + private async createTodoIssueBody(newIssue?: NewIssue, issueBody?: string): Promise { + if (issueBody || newIssue?.document.isUntitled) { + return issueBody; + } + + let contents = ''; + if (newIssue) { + const repository = getRepositoryForFile(this.gitAPI, newIssue.document.uri); + const changeAffectingFile = repository?.state.workingTreeChanges.find(value => value.uri.toString() === newIssue.document.uri.toString()); + if (changeAffectingFile) { + // The file we're creating the issue for has uncommitted changes. + // Add a quote of the line so that the issue body is still meaningful. + contents = `\`\`\`\n${newIssue.line}\n\`\`\`\n\n`; + } + } + contents += (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + return contents; + } + + async createTodoIssue(newIssue?: NewIssue, issueBody?: string) { + let document: vscode.TextDocument; + let titlePlaceholder: string | undefined; + let insertIndex: number | undefined; + let lineNumber: number | undefined; + let assignee: string[] | undefined; + let issueGenerationText: string | undefined; + if (!newIssue && vscode.window.activeTextEditor) { + document = vscode.window.activeTextEditor.document; + issueGenerationText = document.getText(vscode.window.activeTextEditor.selection); + } else if (newIssue) { + document = newIssue.document; + insertIndex = newIssue.insertIndex; + lineNumber = newIssue.lineNumber; + titlePlaceholder = newIssue.line.substring(insertIndex, newIssue.line.length).trim(); + issueGenerationText = document.getText( + newIssue.range.isEmpty ? document.lineAt(newIssue.range.start.line).range : newIssue.range, + ); + } else { + return undefined; + } + const matches = issueGenerationText.match(USER_EXPRESSION); + if (matches && matches.length === 2 && (await this._stateManager.getUserMap(document.uri)).has(matches[1])) { + assignee = [matches[1]]; + } + let title: string | undefined; + const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); + + const quickInput = vscode.window.createInputBox(); + quickInput.value = titlePlaceholder ?? ''; + quickInput.prompt = + vscode.l10n.t('Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'); + quickInput.title = vscode.l10n.t('Create Issue'); + quickInput.buttons = [ + { + iconPath: new vscode.ThemeIcon('edit'), + tooltip: vscode.l10n.t('Edit Description'), + }, + ]; + quickInput.onDidAccept(async () => { + title = quickInput.value; + if (title) { + quickInput.busy = true; + await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, undefined, lineNumber, insertIndex); + quickInput.busy = false; + } + quickInput.hide(); + }); + quickInput.onDidTriggerButton(async () => { + title = quickInput.value; + quickInput.busy = true; + this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; + + this.makeNewIssueFile(document.uri, title, body, assignee); + quickInput.busy = false; + quickInput.hide(); + }); + quickInput.show(); + + return undefined; + } + + private async makeNewIssueFile( + originUri: vscode.Uri, + title?: string, + body?: string, + assignees?: string[] | undefined, + ) { + const query = `?{"origin":"${originUri.toString()}"}`; + const bodyPath = vscode.Uri.parse(`${NEW_ISSUE_SCHEME}:/${NEW_ISSUE_FILE}${query}`); + if ( + vscode.window.visibleTextEditors.filter( + visibleEditor => visibleEditor.document.uri.scheme === NEW_ISSUE_SCHEME, + ).length > 0 + ) { + return; + } + await vscode.workspace.fs.delete(bodyPath); + const assigneeLine = `${ASSIGNEES} ${assignees && assignees.length > 0 ? assignees.map(value => '@' + value).join(', ') + ' ' : '' + }`; + const labelLine = `${LABELS} `; + const milestoneLine = `${MILESTONE} `; + const cached = this._newIssueCache.get(); + const text = (cached && cached !== '') ? cached : `${title ?? vscode.l10n.t('Issue Title')}\n +${assigneeLine} +${labelLine} +${milestoneLine}\n +${body ?? ''}\n +`; + await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); + const assigneesDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated usernames, either @username or just username.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); + const labelsDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated labels.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(textEditor => { + if (textEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { + const assigneeFullLine = textEditor.document.lineAt(2); + if (assigneeFullLine.text.startsWith(ASSIGNEES)) { + textEditor.setDecorations(assigneesDecoration, [ + new vscode.Range( + new vscode.Position(2, 0), + new vscode.Position(2, assigneeFullLine.text.length), + ), + ]); + } + const labelFullLine = textEditor.document.lineAt(3); + if (labelFullLine.text.startsWith(LABELS)) { + textEditor.setDecorations(labelsDecoration, [ + new vscode.Range(new vscode.Position(3, 0), new vscode.Position(3, labelFullLine.text.length)), + ]); + } + } + }); + + const editor = await vscode.window.showTextDocument(bodyPath); + const closeDisposable = vscode.workspace.onDidCloseTextDocument(textDocument => { + if (textDocument === editor.document) { + editorChangeDisposable.dispose(); + closeDisposable.dispose(); + } + }); + } + + private async verifyLabels( + folderManager: FolderRepositoryManager, + createParams: OctokitCommon.IssuesCreateParams, + ): Promise { + if (!createParams.labels) { + return true; + } + const allLabels = (await folderManager.getLabels(undefined, createParams)).map(label => label.name); + const newLabels: string[] = []; + const filteredLabels: string[] = []; + createParams.labels?.forEach(paramLabel => { + let label = typeof paramLabel === 'string' ? paramLabel : paramLabel.name; + if (!label) { + return; + } + + if (allLabels.includes(label)) { + filteredLabels.push(label); + } else { + newLabels.push(label); + } + }); + + if (newLabels.length > 0) { + const yes = vscode.l10n.t('Yes'); + const no = vscode.l10n.t('No'); + const promptResult = await vscode.window.showInformationMessage( + vscode.l10n.t('The following labels don\'t exist in this repository: {0}. \nDo you want to create these labels?', newLabels.join( + ', ', + )), + { modal: true }, + yes, + no, + ); + switch (promptResult) { + case yes: + return true; + case no: { + createParams.labels = filteredLabels; + return true; + } + default: + return false; + } + } + return true; + } + + private async chooseRepo(prompt: string): Promise { + interface RepoChoice extends vscode.QuickPickItem { + repo: FolderRepositoryManager; + } + const choices: RepoChoice[] = []; + for (const folderManager of this.manager.folderManagers) { + try { + const defaults = await folderManager.getPullRequestDefaults(); + choices.push({ + label: `${defaults.owner}/${defaults.repo}`, + repo: folderManager, + }); + } catch (e) { + // ignore + } + } + if (choices.length === 0) { + return; + } else if (choices.length === 1) { + return choices[0].repo; + } + + const choice = await vscode.window.showQuickPick(choices, { placeHolder: prompt }); + return choice?.repo; + } + + private async chooseTemplate(folderManager: FolderRepositoryManager): Promise<{ title: string | undefined, body: string | undefined } | undefined> { + const templateUris = await folderManager.getIssueTemplates(); + if (templateUris.length === 0) { + return undefined; + } + + interface IssueChoice extends vscode.QuickPickItem { + template: IssueTemplate | undefined; + } + const templates = await Promise.all( + templateUris + .map(async uri => { + try { + const content = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder('utf-8').decode(content); + const template = this.getDataFromTemplate(text); + + return template; + } catch (e) { + Logger.warn(`Reading issue template failed: ${e}`); + return undefined; + } + }) + ); + const choices: IssueChoice[] = templates.filter(template => !!template && !!template?.name).map(template => { + return { + label: template!.name!, + description: template!.about, + template: template, + }; + }); + choices.push({ + label: vscode.l10n.t('Blank issue'), + template: undefined + }); + + const selectedTemplate = await vscode.window.showQuickPick(choices, { + placeHolder: vscode.l10n.t('Select a template for the new issue.'), + }); + + return selectedTemplate?.template; + } + + private getDataFromTemplate(template: string): IssueTemplate { + const title = template.match(/title:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const name = template.match(/name:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const about = template.match(/about:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const body = template.match(/---([\s\S]*)---([\s\S]*)/)?.[2]; + return { title, name, about, body }; + } + + private async doCreateIssue( + document: vscode.TextDocument | undefined, + newIssue: NewIssue | undefined, + title: string, + issueBody: string | undefined, + assignees: string[] | undefined, + labels: string[] | undefined, + milestone: number | undefined, + lineNumber: number | undefined, + insertIndex: number | undefined, + originUri?: vscode.Uri, + ): Promise { + let origin: PullRequestDefaults | undefined; + let folderManager: FolderRepositoryManager | undefined; + if (document) { + folderManager = this.manager.getManagerForFile(document.uri); + } else if (originUri) { + folderManager = this.manager.getManagerForFile(originUri); + } + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Choose where to create the issue.')); + } + + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating issue') }, async (progress) => { + if (!folderManager) { + return false; + } + progress.report({ message: vscode.l10n.t('Verifying that issue data is valid...') }); + try { + origin = await folderManager.getPullRequestDefaults(); + } catch (e) { + // There is no remote + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t create an issue.')); + return false; + } + const body: string | undefined = + issueBody || newIssue?.document.isUntitled + ? issueBody + : (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + const createParams: OctokitCommon.IssuesCreateParams = { + owner: origin.owner, + repo: origin.repo, + title, + body, + assignees, + labels, + milestone + }; + if (!(await this.verifyLabels(folderManager, createParams))) { + return false; + } + progress.report({ message: vscode.l10n.t('Creating issue in {0}...', `${createParams.owner}/${createParams.repo}`) }); + const issue = await folderManager.createIssue(createParams); + if (issue) { + if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { + const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); + const insertText: string = + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_INSERT_FORMAT, 'number') === + 'number' + ? `#${issue.number}` + : issue.html_url; + edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); + await vscode.workspace.applyEdit(edit); + } else { + const copyIssueUrl = vscode.l10n.t('Copy Issue Link'); + const openIssue = vscode.l10n.t({ message: 'Open Issue', comment: 'Open the issue description in the browser to see it\'s full contents.' }); + vscode.window.showInformationMessage(vscode.l10n.t('Issue created'), copyIssueUrl, openIssue).then(async result => { + switch (result) { + case copyIssueUrl: + await vscode.env.clipboard.writeText(issue.html_url); + break; + case openIssue: + await vscode.env.openExternal(vscode.Uri.parse(issue.html_url)); + break; + } + }); + } + this._stateManager.refreshCacheNeeded(); + return true; + } + return false; + }); + } + + private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext): Promise { + const link = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); + if (link.error) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', link.error)); + } + return link; + } + + private async getHeadLinkWithError(context?: vscode.Uri, includeRange?: boolean): Promise { + const link = await createGitHubLink(this.manager, context, includeRange); + if (link.error) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', link.error)); + } + return link; + } + + private async getContextualizedLink(file: vscode.Uri, link: string): Promise { + let uri: vscode.Uri; + try { + uri = await vscode.env.asExternalUri(file); + } catch (e) { + // asExternalUri can throw when in the browser and the embedder doesn't set a uri resolver. + return link; + } + const authority = (uri.scheme === 'https' && /^(insiders\.vscode|vscode|github)\./.test(uri.authority)) ? uri.authority : undefined; + if (!authority) { + return link; + } + const linkUri = vscode.Uri.parse(link); + const linkPath = /^(github)\./.test(uri.authority) ? linkUri.path : `/github${linkUri.path}`; + return linkUri.with({ authority, path: linkPath }).toString(); + } + + async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext, includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { + const link = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); + if (link.permalink) { + const contextualizedLink = contextualizeLink && link.originalFile ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink; + Logger.debug(`writing ${contextualizedLink} to the clipboard`, PERMALINK_COMPONENT); + return vscode.env.clipboard.writeText(contextualizedLink); + } + } + + async copyHeadLink(fileUri?: vscode.Uri, includeRange = true) { + const link = await this.getHeadLinkWithError(fileUri, includeRange); + if (link.permalink) { + return vscode.env.clipboard.writeText(link.permalink); + } + } + + private getMarkdownLinkText(): string | undefined { + if (!vscode.window.activeTextEditor) { + return undefined; + } + let editorSelection: vscode.Range | undefined = vscode.window.activeTextEditor.selection; + if (editorSelection.start.line !== editorSelection.end.line) { + editorSelection = new vscode.Range( + editorSelection.start, + new vscode.Position(editorSelection.start.line + 1, 0), + ); + } + const selection = vscode.window.activeTextEditor.document.getText(editorSelection); + if (selection) { + return selection; + } + editorSelection = vscode.window.activeTextEditor.document.getWordRangeAtPosition(editorSelection.start); + if (editorSelection) { + return vscode.window.activeTextEditor.document.getText(editorSelection); + } + return undefined; + } + + async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext, includeRange: boolean = true) { + const link = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); + const selection = this.getMarkdownLinkText(); + if (link.permalink && selection) { + return vscode.env.clipboard.writeText(`[${selection.trim()}](${link.permalink})`); + } + } + + async openPermalink(repositoriesManager: RepositoriesManager) { + const link = await this.getPermalinkWithError(repositoriesManager, true, true); + if (link.permalink) { + return vscode.env.openExternal(vscode.Uri.parse(link.permalink)); + } + return undefined; + } +} diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index 4003aed743..aa886af755 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -1,237 +1,238 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -export const NEW_ISSUE_SCHEME = 'newIssue'; -export const NEW_ISSUE_FILE = 'NewIssue.md'; -export const ASSIGNEES = vscode.l10n.t('Assignees:'); -export const LABELS = vscode.l10n.t('Labels:'); -export const MILESTONE = vscode.l10n.t('Milestone:'); - -const NEW_ISSUE_CACHE = 'newIssue.cache'; - -export function extractIssueOriginFromQuery(uri: vscode.Uri): vscode.Uri | undefined { - const query = JSON.parse(uri.query); - if (query.origin) { - return vscode.Uri.parse(query.origin); - } -} - -export class IssueFileSystemProvider implements vscode.FileSystemProvider { - private content: Uint8Array | undefined; - private createTime: number = 0; - private modifiedTime: number = 0; - private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter< - vscode.FileChangeEvent[] - >(); - - constructor(private readonly cache: NewIssueCache) { } - onDidChangeFile: vscode.Event = this._onDidChangeFile.event; - watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { - const disposable = this.onDidChangeFile(e => { - if (e.length === 0 && e[0].type === vscode.FileChangeType.Deleted) { - disposable.dispose(); - } - }); - return disposable; - } - stat(_uri: vscode.Uri): vscode.FileStat { - return { - type: vscode.FileType.File, - ctime: this.createTime, - mtime: this.modifiedTime, - size: this.content?.length ?? 0, - }; - } - readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { - return []; - } - createDirectory(_uri: vscode.Uri): void { } - readFile(_uri: vscode.Uri): Uint8Array | Thenable { - return this.content ?? new Uint8Array(0); - } - writeFile( - uri: vscode.Uri, - content: Uint8Array, - _options: { create: boolean; overwrite: boolean } = { create: false, overwrite: false }, - ): void | Thenable { - const oldContent = this.content; - this.content = content; - if (oldContent === undefined) { - this.createTime = new Date().getTime(); - this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Created }]); - } else { - this.modifiedTime = new Date().getTime(); - this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Changed }]); - } - this.cache.cache(content); - } - delete(uri: vscode.Uri, _options: { recursive: boolean }): void | Thenable { - this.content = undefined; - this.createTime = 0; - this.modifiedTime = 0; - this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Deleted }]); - } - - rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void | Thenable { } -} - -export class NewIssueFileCompletionProvider implements vscode.CompletionItemProvider { - constructor(private manager: RepositoriesManager) { } - - async provideCompletionItems( - document: vscode.TextDocument, - position: vscode.Position, - _token: vscode.CancellationToken, - _context: vscode.CompletionContext, - ): Promise { - const line = document.lineAt(position.line).text; - if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE)) { - return []; - } - const originFile = extractIssueOriginFromQuery(document.uri); - if (!originFile) { - return []; - } - const folderManager = this.manager.getManagerForFile(originFile); - if (!folderManager) { - return []; - } - const defaults = await folderManager.getPullRequestDefaults(); - - if (line.startsWith(LABELS)) { - return this.provideLabelCompletionItems(folderManager, defaults); - } else if (line.startsWith(MILESTONE)) { - return this.provideMilestoneCompletionItems(folderManager); - } else { - return []; - } - } - - private async provideLabelCompletionItems(folderManager: FolderRepositoryManager, defaults: PullRequestDefaults): Promise { - const labels = await folderManager.getLabels(undefined, defaults); - return labels.map(label => { - const item = new vscode.CompletionItem(label.name, vscode.CompletionItemKind.Color); - item.documentation = `#${label.color}`; - item.commitCharacters = [' ', ',']; - return item; - }); - } - - private async provideMilestoneCompletionItems(folderManager: FolderRepositoryManager): Promise { - const milestones = await (await folderManager.getPullRequestDefaultRepo())?.getMilestones() ?? []; - return milestones.map(milestone => { - const item = new vscode.CompletionItem(milestone.title, vscode.CompletionItemKind.Event); - item.commitCharacters = [' ', ',']; - return item; - }); - } -} - -export class NewIssueCache { - constructor(private readonly context: vscode.ExtensionContext) { - this.clear(); - } - - public cache(issueFileContent: Uint8Array) { - this.context.workspaceState.update(NEW_ISSUE_CACHE, issueFileContent); - } - - public clear() { - this.context.workspaceState.update(NEW_ISSUE_CACHE, undefined); - } - - public get(): string | undefined { - const content = this.context.workspaceState.get(NEW_ISSUE_CACHE); - if (content) { - return new TextDecoder().decode(content); - } - } -} - -export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri } | undefined> { - let text: string; - if ( - !vscode.window.activeTextEditor || - vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME - ) { - return; - } - const originUri = extractIssueOriginFromQuery(vscode.window.activeTextEditor.document.uri); - if (!originUri) { - return; - } - const folderManager = repositoriesManager.getManagerForFile(originUri); - if (!folderManager) { - return; - } - const repo = await folderManager.getPullRequestDefaultRepo(); - text = vscode.window.activeTextEditor.document.getText(); - const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); - const indexOfEmptyLineOther = text.indexOf('\n\n'); - let indexOfEmptyLine: number; - if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { - return; - } else { - if (indexOfEmptyLineWindows < 0) { - indexOfEmptyLine = indexOfEmptyLineOther; - } else if (indexOfEmptyLineOther < 0) { - indexOfEmptyLine = indexOfEmptyLineWindows; - } else { - indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); - } - } - const title = text.substring(0, indexOfEmptyLine); - if (!title) { - return; - } - let assignees: string[] | undefined; - text = text.substring(indexOfEmptyLine + 2).trim(); - if (text.startsWith(ASSIGNEES)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - assignees = lines[0] - .substring(ASSIGNEES.length) - .split(',') - .map(value => { - value = value.trim(); - if (value.startsWith('@')) { - value = value.substring(1); - } - return value; - }); - text = text.substring(lines[0].length).trim(); - } - } - let labels: string[] | undefined; - if (text.startsWith(LABELS)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - labels = lines[0] - .substring(LABELS.length) - .split(',') - .map(value => value.trim()) - .filter(label => label); - text = text.substring(lines[0].length).trim(); - } - } - let milestone: number | undefined; - if (text.startsWith(MILESTONE)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - const milestoneTitle = lines[0].substring(MILESTONE.length).trim(); - if (milestoneTitle) { - const repoMilestones = await repo.getMilestones(); - milestone = repoMilestones?.find(milestone => milestone.title === milestoneTitle)?.number; - } - text = text.substring(lines[0].length).trim(); - } - } - const body = text ?? ''; - return { labels, milestone, assignees, title, body, originUri }; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export const NEW_ISSUE_SCHEME = 'newIssue'; +export const NEW_ISSUE_FILE = 'NewIssue.md'; +export const ASSIGNEES = vscode.l10n.t('Assignees:'); +export const LABELS = vscode.l10n.t('Labels:'); +export const MILESTONE = vscode.l10n.t('Milestone:'); + +const NEW_ISSUE_CACHE = 'newIssue.cache'; + +export function extractIssueOriginFromQuery(uri: vscode.Uri): vscode.Uri | undefined { + const query = JSON.parse(uri.query); + if (query.origin) { + return vscode.Uri.parse(query.origin); + } +} + +export class IssueFileSystemProvider implements vscode.FileSystemProvider { + private content: Uint8Array | undefined; + private createTime: number = 0; + private modifiedTime: number = 0; + private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >(); + + constructor(private readonly cache: NewIssueCache) { } + onDidChangeFile: vscode.Event = this._onDidChangeFile.event; + watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { + const disposable = this.onDidChangeFile(e => { + if (e.length === 0 && e[0].type === vscode.FileChangeType.Deleted) { + disposable.dispose(); + } + }); + return disposable; + } + stat(_uri: vscode.Uri): vscode.FileStat { + return { + type: vscode.FileType.File, + ctime: this.createTime, + mtime: this.modifiedTime, + size: this.content?.length ?? 0, + }; + } + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { + return []; + } + createDirectory(_uri: vscode.Uri): void { } + readFile(_uri: vscode.Uri): Uint8Array | Thenable { + return this.content ?? new Uint8Array(0); + } + writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: { create: boolean; overwrite: boolean } = { create: false, overwrite: false }, + ): void | Thenable { + const oldContent = this.content; + this.content = content; + if (oldContent === undefined) { + this.createTime = new Date().getTime(); + this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Created }]); + } else { + this.modifiedTime = new Date().getTime(); + this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Changed }]); + } + this.cache.cache(content); + } + delete(uri: vscode.Uri, _options: { recursive: boolean }): void | Thenable { + this.content = undefined; + this.createTime = 0; + this.modifiedTime = 0; + this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Deleted }]); + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void | Thenable { } +} + +export class NewIssueFileCompletionProvider implements vscode.CompletionItemProvider { + constructor(private manager: RepositoriesManager) { } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext, + ): Promise { + const line = document.lineAt(position.line).text; + if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE)) { + return []; + } + const originFile = extractIssueOriginFromQuery(document.uri); + if (!originFile) { + return []; + } + const folderManager = this.manager.getManagerForFile(originFile); + if (!folderManager) { + return []; + } + const defaults = await folderManager.getPullRequestDefaults(); + + if (line.startsWith(LABELS)) { + return this.provideLabelCompletionItems(folderManager, defaults); + } else if (line.startsWith(MILESTONE)) { + return this.provideMilestoneCompletionItems(folderManager); + } else { + return []; + } + } + + private async provideLabelCompletionItems(folderManager: FolderRepositoryManager, defaults: PullRequestDefaults): Promise { + const labels = await folderManager.getLabels(undefined, defaults); + return labels.map(label => { + const item = new vscode.CompletionItem(label.name, vscode.CompletionItemKind.Color); + item.documentation = `#${label.color}`; + item.commitCharacters = [' ', ',']; + return item; + }); + } + + private async provideMilestoneCompletionItems(folderManager: FolderRepositoryManager): Promise { + const milestones = await (await folderManager.getPullRequestDefaultRepo())?.getMilestones() ?? []; + return milestones.map(milestone => { + const item = new vscode.CompletionItem(milestone.title, vscode.CompletionItemKind.Event); + item.commitCharacters = [' ', ',']; + return item; + }); + } +} + +export class NewIssueCache { + constructor(private readonly context: vscode.ExtensionContext) { + this.clear(); + } + + public cache(issueFileContent: Uint8Array) { + this.context.workspaceState.update(NEW_ISSUE_CACHE, issueFileContent); + } + + public clear() { + this.context.workspaceState.update(NEW_ISSUE_CACHE, undefined); + } + + public get(): string | undefined { + const content = this.context.workspaceState.get(NEW_ISSUE_CACHE); + if (content) { + return new TextDecoder().decode(content); + } + } +} + +export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri } | undefined> { + let text: string; + if ( + !vscode.window.activeTextEditor || + vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME + ) { + return; + } + const originUri = extractIssueOriginFromQuery(vscode.window.activeTextEditor.document.uri); + if (!originUri) { + return; + } + const folderManager = repositoriesManager.getManagerForFile(originUri); + if (!folderManager) { + return; + } + const repo = await folderManager.getPullRequestDefaultRepo(); + text = vscode.window.activeTextEditor.document.getText(); + const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); + const indexOfEmptyLineOther = text.indexOf('\n\n'); + let indexOfEmptyLine: number; + if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { + return; + } else { + if (indexOfEmptyLineWindows < 0) { + indexOfEmptyLine = indexOfEmptyLineOther; + } else if (indexOfEmptyLineOther < 0) { + indexOfEmptyLine = indexOfEmptyLineWindows; + } else { + indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); + } + } + const title = text.substring(0, indexOfEmptyLine); + if (!title) { + return; + } + let assignees: string[] | undefined; + text = text.substring(indexOfEmptyLine + 2).trim(); + if (text.startsWith(ASSIGNEES)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + assignees = lines[0] + .substring(ASSIGNEES.length) + .split(',') + .map(value => { + value = value.trim(); + if (value.startsWith('@')) { + value = value.substring(1); + } + return value; + }); + text = text.substring(lines[0].length).trim(); + } + } + let labels: string[] | undefined; + if (text.startsWith(LABELS)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + labels = lines[0] + .substring(LABELS.length) + .split(',') + .map(value => value.trim()) + .filter(label => label); + text = text.substring(lines[0].length).trim(); + } + } + let milestone: number | undefined; + if (text.startsWith(MILESTONE)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + const milestoneTitle = lines[0].substring(MILESTONE.length).trim(); + if (milestoneTitle) { + const repoMilestones = await repo.getMilestones(); + milestone = repoMilestones?.find(milestone => milestone.title === milestoneTitle)?.number; + } + text = text.substring(lines[0].length).trim(); + } + } + const body = text ?? ''; + return { labels, milestone, assignees, title, body, originUri }; +} diff --git a/src/issues/issueHoverProvider.ts b/src/issues/issueHoverProvider.ts index 4092d13dc3..5173b0f58d 100644 --- a/src/issues/issueHoverProvider.ts +++ b/src/issues/issueHoverProvider.ts @@ -1,79 +1,80 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { ITelemetry } from '../common/telemetry'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; -import { StateManager } from './stateManager'; -import { - getIssue, - issueMarkdown, - shouldShowHover, -} from './util'; - -export class IssueHoverProvider implements vscode.HoverProvider { - constructor( - private manager: RepositoriesManager, - private stateManager: StateManager, - private context: vscode.ExtensionContext, - private telemetry: ITelemetry, - ) { } - - async provideHover( - document: vscode.TextDocument, - position: vscode.Position, - _token: vscode.CancellationToken, - ): Promise { - if (!(await shouldShowHover(document, position))) { - return; - } - - let wordPosition = document.getWordRangeAtPosition(position, ISSUE_OR_URL_EXPRESSION); - if (wordPosition && wordPosition.start.character > 0) { - wordPosition = new vscode.Range( - new vscode.Position(wordPosition.start.line, wordPosition.start.character), - wordPosition.end, - ); - const word = document.getText(wordPosition); - const match = word.match(ISSUE_OR_URL_EXPRESSION); - const tryParsed = parseIssueExpressionOutput(match); - - const folderManager = this.manager.getManagerForFile(document.uri) ?? this.manager.folderManagers[0]; - if (!folderManager) { - return; - } - - if ( - tryParsed && - match && - // Only check the max issue number if the owner/repo format isn't used here. - (tryParsed.owner || tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri)) - ) { - return this.createHover(folderManager, match[0], tryParsed, wordPosition); - } - } else { - return; - } - } - - private async createHover( - folderManager: FolderRepositoryManager, - value: string, - parsed: ParsedIssue, - range: vscode.Range, - ): Promise { - const issue = await getIssue(this.stateManager, folderManager, value, parsed); - if (!issue) { - return; - } - /* __GDPR__ - "issue.issueHover" : {} - */ - this.telemetry.sendTelemetryEvent('issues.issueHover'); - return new vscode.Hover(await issueMarkdown(issue, this.context, this.manager, parsed.commentNumber), range); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { ITelemetry } from '../common/telemetry'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; +import { StateManager } from './stateManager'; +import { + getIssue, + issueMarkdown, + shouldShowHover, +} from './util'; + +export class IssueHoverProvider implements vscode.HoverProvider { + constructor( + private manager: RepositoriesManager, + private stateManager: StateManager, + private context: vscode.ExtensionContext, + private telemetry: ITelemetry, + ) { } + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + ): Promise { + if (!(await shouldShowHover(document, position))) { + return; + } + + let wordPosition = document.getWordRangeAtPosition(position, ISSUE_OR_URL_EXPRESSION); + if (wordPosition && wordPosition.start.character > 0) { + wordPosition = new vscode.Range( + new vscode.Position(wordPosition.start.line, wordPosition.start.character), + wordPosition.end, + ); + const word = document.getText(wordPosition); + const match = word.match(ISSUE_OR_URL_EXPRESSION); + const tryParsed = parseIssueExpressionOutput(match); + + const folderManager = this.manager.getManagerForFile(document.uri) ?? this.manager.folderManagers[0]; + if (!folderManager) { + return; + } + + if ( + tryParsed && + match && + // Only check the max issue number if the owner/repo format isn't used here. + (tryParsed.owner || tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri)) + ) { + return this.createHover(folderManager, match[0], tryParsed, wordPosition); + } + } else { + return; + } + } + + private async createHover( + folderManager: FolderRepositoryManager, + value: string, + parsed: ParsedIssue, + range: vscode.Range, + ): Promise { + const issue = await getIssue(this.stateManager, folderManager, value, parsed); + if (!issue) { + return; + } + /* __GDPR__ + "issue.issueHover" : {} + */ + this.telemetry.sendTelemetryEvent('issues.issueHover'); + return new vscode.Hover(await issueMarkdown(issue, this.context, this.manager, parsed.commentNumber), range); + } +} diff --git a/src/issues/issueLinkLookup.ts b/src/issues/issueLinkLookup.ts index f90468cd3d..f22e6e9567 100644 --- a/src/issues/issueLinkLookup.ts +++ b/src/issues/issueLinkLookup.ts @@ -1,89 +1,90 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -export const CODE_PERMALINK = /http(s)?\:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([0-9a-fA-F]{40})\/([^#]+)#L(\d+)(-L(\d+))?/; - -function findCodeLink(issueContent: string): RegExpMatchArray | null { - return issueContent.match(CODE_PERMALINK); -} - -export function issueBodyHasLink(issueModel: IssueModel): boolean { - return !!findCodeLink(issueModel.body); -} - -interface CodeLink { - file: vscode.Uri; - start: number; - end: number; -} - -export async function findCodeLinkLocally( - codeLink: RegExpMatchArray, - repositoriesManager: RepositoriesManager, - _silent: boolean = true, -): Promise { - const owner = codeLink[2]; - const repo = codeLink[3]; - const repoSubPath = codeLink[5]; - // subtract 1 because VS Code starts lines at 0, whereas GitHub starts at 1. - const startingLine = Number(codeLink[6]) - 1; - const endingLine = codeLink[8] ? Number(codeLink[8]) - 1 : startingLine; - let linkFolderManager: FolderRepositoryManager | undefined; - - for (const folderManager of repositoriesManager.folderManagers) { - const remotes = await folderManager.getGitHubRemotes(); - for (const remote of remotes) { - if ( - owner.toLowerCase() === remote.owner.toLowerCase() && - repo.toLowerCase() === remote.repositoryName.toLowerCase() - ) { - linkFolderManager = folderManager; - break; - } - } - if (linkFolderManager) { - break; - } - } - - if (!linkFolderManager) { - return; - } - - const path = vscode.Uri.joinPath(linkFolderManager.repository.rootUri, repoSubPath); - try { - await vscode.workspace.fs.stat(path); - } catch (e) { - return; - } - return { - file: path, - start: startingLine, - end: endingLine, - }; -} - -export async function openCodeLink(issueModel: IssueModel, repositoriesManager: RepositoriesManager) { - const issueLink = findCodeLink(issueModel.body); - if (!issueLink) { - vscode.window.showInformationMessage(vscode.l10n.t('Issue has no link.')); - return; - } - const codeLink = await findCodeLinkLocally(issueLink, repositoriesManager, false); - if (!codeLink) { - return vscode.env.openExternal(vscode.Uri.parse(issueLink[0])); - } - const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); - const endingTextDocumentLine = textDocument.lineAt( - codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, - ); - const selection = new vscode.Range(codeLink.start, 0, codeLink.end, endingTextDocumentLine.text.length); - return vscode.window.showTextDocument(codeLink.file, { selection }); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { IssueModel } from '../github/issueModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export const CODE_PERMALINK = /http(s)?\:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([0-9a-fA-F]{40})\/([^#]+)#L(\d+)(-L(\d+))?/; + +function findCodeLink(issueContent: string): RegExpMatchArray | null { + return issueContent.match(CODE_PERMALINK); +} + +export function issueBodyHasLink(issueModel: IssueModel): boolean { + return !!findCodeLink(issueModel.body); +} + +interface CodeLink { + file: vscode.Uri; + start: number; + end: number; +} + +export async function findCodeLinkLocally( + codeLink: RegExpMatchArray, + repositoriesManager: RepositoriesManager, + _silent: boolean = true, +): Promise { + const owner = codeLink[2]; + const repo = codeLink[3]; + const repoSubPath = codeLink[5]; + // subtract 1 because VS Code starts lines at 0, whereas GitHub starts at 1. + const startingLine = Number(codeLink[6]) - 1; + const endingLine = codeLink[8] ? Number(codeLink[8]) - 1 : startingLine; + let linkFolderManager: FolderRepositoryManager | undefined; + + for (const folderManager of repositoriesManager.folderManagers) { + const remotes = await folderManager.getGitHubRemotes(); + for (const remote of remotes) { + if ( + owner.toLowerCase() === remote.owner.toLowerCase() && + repo.toLowerCase() === remote.repositoryName.toLowerCase() + ) { + linkFolderManager = folderManager; + break; + } + } + if (linkFolderManager) { + break; + } + } + + if (!linkFolderManager) { + return; + } + + const path = vscode.Uri.joinPath(linkFolderManager.repository.rootUri, repoSubPath); + try { + await vscode.workspace.fs.stat(path); + } catch (e) { + return; + } + return { + file: path, + start: startingLine, + end: endingLine, + }; +} + +export async function openCodeLink(issueModel: IssueModel, repositoriesManager: RepositoriesManager) { + const issueLink = findCodeLink(issueModel.body); + if (!issueLink) { + vscode.window.showInformationMessage(vscode.l10n.t('Issue has no link.')); + return; + } + const codeLink = await findCodeLinkLocally(issueLink, repositoriesManager, false); + if (!codeLink) { + return vscode.env.openExternal(vscode.Uri.parse(issueLink[0])); + } + const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); + const endingTextDocumentLine = textDocument.lineAt( + codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, + ); + const selection = new vscode.Range(codeLink.start, 0, codeLink.end, endingTextDocumentLine.text.length); + return vscode.window.showTextDocument(codeLink.file, { selection }); +} diff --git a/src/issues/issueLinkProvider.ts b/src/issues/issueLinkProvider.ts index 3410d1a837..6e39ba8fb6 100644 --- a/src/issues/issueLinkProvider.ts +++ b/src/issues/issueLinkProvider.ts @@ -1,89 +1,90 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; -import { EDITOR, WORD_WRAP } from '../common/settingKeys'; -import { ReposManagerState } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; -import { StateManager } from './stateManager'; -import { - getIssue, - isComment, - MAX_LINE_LENGTH, -} from './util'; - -const MAX_LINE_COUNT = 2000; - -class IssueDocumentLink extends vscode.DocumentLink { - constructor( - range: vscode.Range, - public readonly mappedLink: { readonly value: string; readonly parsed: ParsedIssue }, - public readonly uri: vscode.Uri, - ) { - super(range); - } -} - -export class IssueLinkProvider implements vscode.DocumentLinkProvider { - constructor(private manager: RepositoriesManager, private stateManager: StateManager) { } - - async provideDocumentLinks( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { - const links: vscode.DocumentLink[] = []; - const wraps: boolean = vscode.workspace.getConfiguration(EDITOR, document).get(WORD_WRAP, 'off') !== 'off'; - for (let i = 0; i < Math.min(document.lineCount, MAX_LINE_COUNT); i++) { - let searchResult = -1; - let lineOffset = 0; - const line = document.lineAt(i).text; - const lineLength = wraps ? line.length : Math.min(line.length, MAX_LINE_LENGTH); - let lineSubstring = line.substring(0, lineLength); - while ((searchResult = lineSubstring.search(ISSUE_EXPRESSION)) >= 0) { - const match = lineSubstring.match(ISSUE_EXPRESSION); - const parsed = parseIssueExpressionOutput(match); - if (match && parsed) { - const startPosition = new vscode.Position(i, searchResult + lineOffset); - if (await isComment(document, startPosition)) { - const link = new IssueDocumentLink( - new vscode.Range( - startPosition, - new vscode.Position(i, searchResult + lineOffset + match[0].length - 1), - ), - { value: match[0], parsed }, - document.uri, - ); - links.push(link); - } - } - lineOffset += searchResult + (match ? match[0].length : 0); - lineSubstring = line.substring(lineOffset, line.length); - } - } - return links; - } - - async resolveDocumentLink( - link: IssueDocumentLink, - _token: vscode.CancellationToken, - ): Promise { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - const folderManager = this.manager.getManagerForFile(link.uri); - if (!folderManager) { - return; - } - const issue = await getIssue( - this.stateManager, - folderManager, - link.mappedLink.value, - link.mappedLink.parsed, - ); - if (issue) { - link.target = await vscode.env.asExternalUri(vscode.Uri.parse(issue.html_url)); - } - return link; - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +import { EDITOR, WORD_WRAP } from '../common/settingKeys'; +import { ReposManagerState } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; +import { StateManager } from './stateManager'; +import { + getIssue, + isComment, + MAX_LINE_LENGTH, +} from './util'; + +const MAX_LINE_COUNT = 2000; + +class IssueDocumentLink extends vscode.DocumentLink { + constructor( + range: vscode.Range, + public readonly mappedLink: { readonly value: string; readonly parsed: ParsedIssue }, + public readonly uri: vscode.Uri, + ) { + super(range); + } +} + +export class IssueLinkProvider implements vscode.DocumentLinkProvider { + constructor(private manager: RepositoriesManager, private stateManager: StateManager) { } + + async provideDocumentLinks( + document: vscode.TextDocument, + _token: vscode.CancellationToken, + ): Promise { + const links: vscode.DocumentLink[] = []; + const wraps: boolean = vscode.workspace.getConfiguration(EDITOR, document).get(WORD_WRAP, 'off') !== 'off'; + for (let i = 0; i < Math.min(document.lineCount, MAX_LINE_COUNT); i++) { + let searchResult = -1; + let lineOffset = 0; + const line = document.lineAt(i).text; + const lineLength = wraps ? line.length : Math.min(line.length, MAX_LINE_LENGTH); + let lineSubstring = line.substring(0, lineLength); + while ((searchResult = lineSubstring.search(ISSUE_EXPRESSION)) >= 0) { + const match = lineSubstring.match(ISSUE_EXPRESSION); + const parsed = parseIssueExpressionOutput(match); + if (match && parsed) { + const startPosition = new vscode.Position(i, searchResult + lineOffset); + if (await isComment(document, startPosition)) { + const link = new IssueDocumentLink( + new vscode.Range( + startPosition, + new vscode.Position(i, searchResult + lineOffset + match[0].length - 1), + ), + { value: match[0], parsed }, + document.uri, + ); + links.push(link); + } + } + lineOffset += searchResult + (match ? match[0].length : 0); + lineSubstring = line.substring(lineOffset, line.length); + } + } + return links; + } + + async resolveDocumentLink( + link: IssueDocumentLink, + _token: vscode.CancellationToken, + ): Promise { + if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + const folderManager = this.manager.getManagerForFile(link.uri); + if (!folderManager) { + return; + } + const issue = await getIssue( + this.stateManager, + folderManager, + link.mappedLink.value, + link.mappedLink.parsed, + ); + if (issue) { + link.target = await vscode.env.asExternalUri(vscode.Uri.parse(issue.html_url)); + } + return link; + } + } +} diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index 8a79503487..9992b64086 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -1,67 +1,68 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; -import { MAX_LINE_LENGTH } from './util'; - -export class IssueTodoProvider implements vscode.CodeActionProvider { - private expression: RegExp | undefined; - - constructor(context: vscode.ExtensionContext) { - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(() => { - this.updateTriggers(); - }), - ); - this.updateTriggers(); - } - - private updateTriggers() { - const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []); - this.expression = triggers.length > 0 ? new RegExp(triggers.join('|')) : undefined; - } - - async provideCodeActions( - document: vscode.TextDocument, - range: vscode.Range | vscode.Selection, - context: vscode.CodeActionContext, - _token: vscode.CancellationToken, - ): Promise { - if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) { - return []; - } - const codeActions: vscode.CodeAction[] = []; - let lineNumber = range.start.line; - do { - const line = document.lineAt(lineNumber).text; - const truncatedLine = line.substring(0, MAX_LINE_LENGTH); - const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION); - if (!matches) { - const search = truncatedLine.search(this.expression); - if (search >= 0) { - const codeAction: vscode.CodeAction = new vscode.CodeAction( - vscode.l10n.t('Create GitHub Issue'), - vscode.CodeActionKind.QuickFix, - ); - const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); - const insertIndex = - search + - (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length); - codeAction.command = { - title: vscode.l10n.t('Create GitHub Issue'), - command: 'issue.createIssueFromSelection', - arguments: [{ document, lineNumber, line, insertIndex, range }], - }; - codeActions.push(codeAction); - break; - } - } - lineNumber++; - } while (range.end.line >= lineNumber); - return codeActions; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; +import { MAX_LINE_LENGTH } from './util'; + +export class IssueTodoProvider implements vscode.CodeActionProvider { + private expression: RegExp | undefined; + + constructor(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(() => { + this.updateTriggers(); + }), + ); + this.updateTriggers(); + } + + private updateTriggers() { + const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []); + this.expression = triggers.length > 0 ? new RegExp(triggers.join('|')) : undefined; + } + + async provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + _token: vscode.CancellationToken, + ): Promise { + if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) { + return []; + } + const codeActions: vscode.CodeAction[] = []; + let lineNumber = range.start.line; + do { + const line = document.lineAt(lineNumber).text; + const truncatedLine = line.substring(0, MAX_LINE_LENGTH); + const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION); + if (!matches) { + const search = truncatedLine.search(this.expression); + if (search >= 0) { + const codeAction: vscode.CodeAction = new vscode.CodeAction( + vscode.l10n.t('Create GitHub Issue'), + vscode.CodeActionKind.QuickFix, + ); + const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); + const insertIndex = + search + + (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length); + codeAction.command = { + title: vscode.l10n.t('Create GitHub Issue'), + command: 'issue.createIssueFromSelection', + arguments: [{ document, lineNumber, line, insertIndex, range }], + }; + codeActions.push(codeAction); + break; + } + } + lineNumber++; + } while (range.end.line >= lineNumber); + return codeActions; + } +} diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index 52b1792af9..d31bc72d9c 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -1,230 +1,231 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { commands, contexts } from '../common/executeCommands'; -import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { issueBodyHasLink } from './issueLinkLookup'; -import { IssueItem, MilestoneItem, StateManager } from './stateManager'; -import { issueMarkdown } from './util'; - -export class IssueUriTreeItem extends vscode.TreeItem { - constructor( - public readonly uri: vscode.Uri | undefined, - label: string, - collapsibleState?: vscode.TreeItemCollapsibleState, - ) { - super(label, collapsibleState); - } - - get labelAsString(): string | undefined { - return typeof this.label === 'string' ? this.label : this.label?.label; - } -} - -export class IssuesTreeData - implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter< - FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void - > = new vscode.EventEmitter(); - public onDidChangeTreeData: vscode.Event< - FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void - > = this._onDidChangeTreeData.event; - - constructor( - private stateManager: StateManager, - private manager: RepositoriesManager, - private context: vscode.ExtensionContext, - ) { - context.subscriptions.push( - this.manager.onDidChangeState(() => { - this._onDidChangeTreeData.fire(); - }), - ); - context.subscriptions.push( - this.stateManager.onDidChangeIssueData(() => { - this._onDidChangeTreeData.fire(); - }), - ); - - context.subscriptions.push( - this.stateManager.onDidChangeCurrentIssue(() => { - this._onDidChangeTreeData.fire(); - }), - ); - } - - getTreeItem(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem): IssueUriTreeItem { - let treeItem: IssueUriTreeItem; - if (element instanceof IssueUriTreeItem) { - treeItem = element; - treeItem.collapsibleState = getQueryExpandState(this.context, element, element.collapsibleState); - } else if (element instanceof FolderRepositoryManager) { - treeItem = new IssueUriTreeItem( - element.repository.rootUri, - path.basename(element.repository.rootUri.fsPath), - getQueryExpandState(this.context, element, vscode.TreeItemCollapsibleState.Expanded) - ); - } else if (!(element instanceof IssueModel)) { - treeItem = new IssueUriTreeItem( - element.uri, - element.milestone.title, - getQueryExpandState(this.context, element, element.issues.length > 0 - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.None) - ); - } else { - treeItem = new IssueUriTreeItem( - undefined, - `${element.number}: ${element.title}`, - vscode.TreeItemCollapsibleState.None, - ); - treeItem.iconPath = element.isOpen - ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) - : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); - if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label as string}`; - treeItem.contextValue = 'currentissue'; - } else { - const savedState = this.stateManager.getSavedIssueState(element.number); - if (savedState.branch) { - treeItem.contextValue = 'continueissue'; - } else { - treeItem.contextValue = 'issue'; - } - } - if (issueBodyHasLink(element)) { - treeItem.contextValue = 'link' + treeItem.contextValue; - } - } - return treeItem; - } - - getChildren( - element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, - ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { - if (element === undefined && this.manager.state !== ReposManagerState.RepositoriesLoaded) { - return this.getStateChildren(); - } else { - return this.getIssuesChildren(element); - } - } - - async resolveTreeItem( - item: vscode.TreeItem, - element: FolderRepositoryManager | IssueItem | MilestoneItem | vscode.TreeItem, - ): Promise { - if (element instanceof IssueModel) { - item.tooltip = await issueMarkdown(element, this.context, this.manager); - } - return item; - } - - getStateChildren(): IssueUriTreeItem[] { - if ((this.manager.state === ReposManagerState.NeedsAuthentication) - || !this.manager.folderManagers.length) { - return []; - } else { - commands.setContext(contexts.LOADING_ISSUES_TREE, true); - return []; - } - } - - getQueryItems(folderManager: FolderRepositoryManager): Promise<(IssueItem | MilestoneItem)[]> | IssueUriTreeItem[] { - const issueCollection = this.stateManager.getIssueCollection(folderManager.repository.rootUri); - if (issueCollection.size === 1) { - return Array.from(issueCollection.values())[0]; - } - const queryLabels = Array.from(issueCollection.keys()); - const firstLabel = queryLabels[0]; - return queryLabels.map(label => { - const item = new IssueUriTreeItem(folderManager.repository.rootUri, label); - item.contextValue = 'query'; - item.collapsibleState = - label === firstLabel - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed; - return item; - }); - } - - getIssuesChildren( - element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, - ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { - if (element === undefined) { - // If there's only one query, don't display a title for it - if (this.manager.folderManagers.length === 1) { - return this.getQueryItems(this.manager.folderManagers[0]); - } else if (this.manager.folderManagers.length > 1) { - return this.manager.folderManagers; - } else { - return []; - } - } else if (element instanceof FolderRepositoryManager) { - return this.getQueryItems(element); - } else if (element instanceof IssueUriTreeItem) { - return element.uri - ? this.stateManager.getIssueCollection(element.uri).get(element.labelAsString!) ?? [] - : []; - } else if (!(element instanceof IssueModel)) { - return element.issues.map(item => { - const issueItem: IssueItem = Object.assign(item); - issueItem.uri = element.uri; - return issueItem; - }); - } else { - return []; - } - } -} - -const EXPANDED_ISSUES_STATE = 'expandedIssuesState'; - -function expandStateId(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined) { - if (!element) { - return; - } - let id: string | undefined; - if (element instanceof IssueUriTreeItem) { - id = element.labelAsString; - } else if (element instanceof vscode.TreeItem) { - // No id needed - } else if (element instanceof FolderRepositoryManager) { - id = element.repository.rootUri.toString(); - } else if (!(element instanceof IssueModel)) { - id = element.milestone.title; - } - return id; -} - -export function updateExpandedQueries(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined, isExpanded: boolean) { - const id = expandStateId(element); - - if (id) { - const expandedQueries = new Set(context.workspaceState.get(EXPANDED_ISSUES_STATE, []) as string[]); - if (isExpanded) { - expandedQueries.add(id); - } else { - expandedQueries.delete(id); - } - context.workspaceState.update(EXPANDED_ISSUES_STATE, Array.from(expandedQueries.keys())); - } -} - -function getQueryExpandState(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, defaultState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.Collapsed): vscode.TreeItemCollapsibleState { - const id = expandStateId(element); - if (id) { - const savedValue = context.workspaceState.get(EXPANDED_ISSUES_STATE); - if (!savedValue) { - return defaultState; - } - const expandedQueries = new Set(savedValue as string[]); - return expandedQueries.has(id) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; - } - return vscode.TreeItemCollapsibleState.None; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { commands, contexts } from '../common/executeCommands'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { IssueModel } from '../github/issueModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { issueBodyHasLink } from './issueLinkLookup'; +import { IssueItem, MilestoneItem, StateManager } from './stateManager'; +import { issueMarkdown } from './util'; + +export class IssueUriTreeItem extends vscode.TreeItem { + constructor( + public readonly uri: vscode.Uri | undefined, + label: string, + collapsibleState?: vscode.TreeItemCollapsibleState, + ) { + super(label, collapsibleState); + } + + get labelAsString(): string | undefined { + return typeof this.label === 'string' ? this.label : this.label?.label; + } +} + +export class IssuesTreeData + implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter< + FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void + > = new vscode.EventEmitter(); + public onDidChangeTreeData: vscode.Event< + FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void + > = this._onDidChangeTreeData.event; + + constructor( + private stateManager: StateManager, + private manager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { + context.subscriptions.push( + this.manager.onDidChangeState(() => { + this._onDidChangeTreeData.fire(); + }), + ); + context.subscriptions.push( + this.stateManager.onDidChangeIssueData(() => { + this._onDidChangeTreeData.fire(); + }), + ); + + context.subscriptions.push( + this.stateManager.onDidChangeCurrentIssue(() => { + this._onDidChangeTreeData.fire(); + }), + ); + } + + getTreeItem(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem): IssueUriTreeItem { + let treeItem: IssueUriTreeItem; + if (element instanceof IssueUriTreeItem) { + treeItem = element; + treeItem.collapsibleState = getQueryExpandState(this.context, element, element.collapsibleState); + } else if (element instanceof FolderRepositoryManager) { + treeItem = new IssueUriTreeItem( + element.repository.rootUri, + path.basename(element.repository.rootUri.fsPath), + getQueryExpandState(this.context, element, vscode.TreeItemCollapsibleState.Expanded) + ); + } else if (!(element instanceof IssueModel)) { + treeItem = new IssueUriTreeItem( + element.uri, + element.milestone.title, + getQueryExpandState(this.context, element, element.issues.length > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None) + ); + } else { + treeItem = new IssueUriTreeItem( + undefined, + `${element.number}: ${element.title}`, + vscode.TreeItemCollapsibleState.None, + ); + treeItem.iconPath = element.isOpen + ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); + if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { + treeItem.label = `✓ ${treeItem.label as string}`; + treeItem.contextValue = 'currentissue'; + } else { + const savedState = this.stateManager.getSavedIssueState(element.number); + if (savedState.branch) { + treeItem.contextValue = 'continueissue'; + } else { + treeItem.contextValue = 'issue'; + } + } + if (issueBodyHasLink(element)) { + treeItem.contextValue = 'link' + treeItem.contextValue; + } + } + return treeItem; + } + + getChildren( + element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, + ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { + if (element === undefined && this.manager.state !== ReposManagerState.RepositoriesLoaded) { + return this.getStateChildren(); + } else { + return this.getIssuesChildren(element); + } + } + + async resolveTreeItem( + item: vscode.TreeItem, + element: FolderRepositoryManager | IssueItem | MilestoneItem | vscode.TreeItem, + ): Promise { + if (element instanceof IssueModel) { + item.tooltip = await issueMarkdown(element, this.context, this.manager); + } + return item; + } + + getStateChildren(): IssueUriTreeItem[] { + if ((this.manager.state === ReposManagerState.NeedsAuthentication) + || !this.manager.folderManagers.length) { + return []; + } else { + commands.setContext(contexts.LOADING_ISSUES_TREE, true); + return []; + } + } + + getQueryItems(folderManager: FolderRepositoryManager): Promise<(IssueItem | MilestoneItem)[]> | IssueUriTreeItem[] { + const issueCollection = this.stateManager.getIssueCollection(folderManager.repository.rootUri); + if (issueCollection.size === 1) { + return Array.from(issueCollection.values())[0]; + } + const queryLabels = Array.from(issueCollection.keys()); + const firstLabel = queryLabels[0]; + return queryLabels.map(label => { + const item = new IssueUriTreeItem(folderManager.repository.rootUri, label); + item.contextValue = 'query'; + item.collapsibleState = + label === firstLabel + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + return item; + }); + } + + getIssuesChildren( + element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, + ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { + if (element === undefined) { + // If there's only one query, don't display a title for it + if (this.manager.folderManagers.length === 1) { + return this.getQueryItems(this.manager.folderManagers[0]); + } else if (this.manager.folderManagers.length > 1) { + return this.manager.folderManagers; + } else { + return []; + } + } else if (element instanceof FolderRepositoryManager) { + return this.getQueryItems(element); + } else if (element instanceof IssueUriTreeItem) { + return element.uri + ? this.stateManager.getIssueCollection(element.uri).get(element.labelAsString!) ?? [] + : []; + } else if (!(element instanceof IssueModel)) { + return element.issues.map(item => { + const issueItem: IssueItem = Object.assign(item); + issueItem.uri = element.uri; + return issueItem; + }); + } else { + return []; + } + } +} + +const EXPANDED_ISSUES_STATE = 'expandedIssuesState'; + +function expandStateId(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined) { + if (!element) { + return; + } + let id: string | undefined; + if (element instanceof IssueUriTreeItem) { + id = element.labelAsString; + } else if (element instanceof vscode.TreeItem) { + // No id needed + } else if (element instanceof FolderRepositoryManager) { + id = element.repository.rootUri.toString(); + } else if (!(element instanceof IssueModel)) { + id = element.milestone.title; + } + return id; +} + +export function updateExpandedQueries(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined, isExpanded: boolean) { + const id = expandStateId(element); + + if (id) { + const expandedQueries = new Set(context.workspaceState.get(EXPANDED_ISSUES_STATE, []) as string[]); + if (isExpanded) { + expandedQueries.add(id); + } else { + expandedQueries.delete(id); + } + context.workspaceState.update(EXPANDED_ISSUES_STATE, Array.from(expandedQueries.keys())); + } +} + +function getQueryExpandState(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, defaultState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.Collapsed): vscode.TreeItemCollapsibleState { + const id = expandStateId(element); + if (id) { + const savedValue = context.workspaceState.get(EXPANDED_ISSUES_STATE); + if (!savedValue) { + return defaultState; + } + const expandedQueries = new Set(savedValue as string[]); + return expandedQueries.has(id) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; + } + return vscode.TreeItemCollapsibleState.None; +} diff --git a/src/issues/shareProviders.ts b/src/issues/shareProviders.ts index 26e7d55a04..2f7bc1e156 100644 --- a/src/issues/shareProviders.ts +++ b/src/issues/shareProviders.ts @@ -1,283 +1,284 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as pathLib from 'path'; -import * as vscode from 'vscode'; -import { Commit, Remote, Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { fromReviewUri, Schemes } from '../common/uri'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { encodeURIComponentExceptSlashes, getBestPossibleUpstream, getOwnerAndRepo, getSimpleUpstream, getUpstreamOrigin, rangeString } from './util'; - -export class ShareProviderManager implements vscode.Disposable { - private disposables: vscode.Disposable[] = []; - - constructor(repositoryManager: RepositoriesManager, gitAPI: GitApiImpl) { - if (!vscode.window.registerShareProvider) { - return; - } - - this.disposables.push( - new GitHubDevShareProvider(repositoryManager, gitAPI), - new GitHubPermalinkShareProvider(repositoryManager, gitAPI), - new GitHubPermalinkAsMarkdownShareProvider(repositoryManager, gitAPI), - new GitHubHeadLinkShareProvider(repositoryManager, gitAPI) - ); - } - - dispose() { - this.disposables.forEach((d) => d.dispose()); - } -} - -const supportedSchemes = [Schemes.File, Schemes.Review, Schemes.Pr, Schemes.VscodeVfs]; - -abstract class AbstractShareProvider implements vscode.Disposable, vscode.ShareProvider { - private disposables: vscode.Disposable[] = []; - protected shareProviderRegistrations: vscode.Disposable[] | undefined; - - constructor( - protected repositoryManager: RepositoriesManager, - protected gitAPI: GitApiImpl, - public readonly id: string, - public readonly label: string, - public readonly priority: number, - private readonly origin = 'github.com' - ) { - this.initialize(); - } - - public dispose() { - this.disposables.forEach((d) => d.dispose()); - this.shareProviderRegistrations?.map((d) => d.dispose()); - } - - private async initialize() { - if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { - this.register(); - } - - this.disposables.push(this.repositoryManager.onDidLoadAnyRepositories(async () => { - if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { - this.register(); - } - })); - - this.disposables.push(this.gitAPI.onDidCloseRepository(() => { - if (!this.hasGitHubRepositories()) { - this.unregister(); - } - })); - } - - private async hasGitHubRepositories() { - for (const folderManager of this.repositoryManager.folderManagers) { - if ((await folderManager.computeAllGitHubRemotes()).length) { - return true; - } - return false; - } - } - - private register() { - if (this.shareProviderRegistrations) { - return; - } - - this.shareProviderRegistrations = supportedSchemes.map((scheme) => vscode.window.registerShareProvider({ scheme }, this)); - } - - private unregister() { - this.shareProviderRegistrations?.map((d) => d.dispose()); - this.shareProviderRegistrations = undefined; - } - - protected abstract shouldRegister(): boolean; - protected abstract getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise; - protected abstract getUpstream(repository: Repository, commit: string): Promise; - - public async provideShare(item: vscode.ShareableItem): Promise { - // Get the blob - const folderManager = this.repositoryManager.getManagerForFile(item.resourceUri); - if (!folderManager) { - throw new Error(vscode.l10n.t('Current file does not belong to an open repository.')); - } - const blob = await this.getBlob(folderManager, item.resourceUri); - - // Get the upstream - const repository = folderManager.repository; - const remote = await this.getUpstream(repository, blob); - if (!remote || !remote.fetchUrl) { - throw new Error(vscode.l10n.t('The selection may not exist on any remote.')); - } - - const origin = getUpstreamOrigin(remote, this.origin).replace(/\/$/, ''); - const path = encodeURIComponentExceptSlashes(item.resourceUri.path.substring(repository.rootUri.path.length)); - const range = getRangeSegment(item); - - return vscode.Uri.parse([ - origin, - '/', - getOwnerAndRepo(this.repositoryManager, repository, { ...remote, fetchUrl: remote.fetchUrl }), - '/blob/', - blob, - path, - range - ].join('')); - } -} - -export class GitHubDevShareProvider extends AbstractShareProvider implements vscode.ShareProvider { - constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { - super(repositoryManager, gitApi, 'githubDevLink', vscode.l10n.t('Copy github.dev Link'), 10, 'github.dev'); - } - - protected shouldRegister(): boolean { - return vscode.env.appHost === 'github.dev'; - } - - protected async getBlob(folderManager: FolderRepositoryManager): Promise { - return getHEAD(folderManager); - } - - protected async getUpstream(repository: Repository): Promise { - return getSimpleUpstream(repository); - } -} - -export class GitHubPermalinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { - constructor( - repositoryManager: RepositoriesManager, - gitApi: GitApiImpl, - id: string = 'githubComPermalink', - label: string = vscode.l10n.t('Copy GitHub Permalink'), - priority: number = 11 - ) { - super(repositoryManager, gitApi, id, label, priority); - } - - protected shouldRegister() { - return true; - } - - protected async getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise { - let commit: Commit | undefined; - let commitHash: string | undefined; - if (uri.scheme === Schemes.Review) { - commitHash = fromReviewUri(uri.query).commit; - } - - if (!commitHash) { - const repository = folderManager.repository; - try { - const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); - if (log.length === 0) { - throw new Error(vscode.l10n.t('No branch on a remote contains the most recent commit for the file.')); - } - // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. - if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { - commit = await repository.getCommit(repository.state.HEAD.commit); - } - if (!commit) { - commit = log[0]; - } - commitHash = commit.hash; - } catch (e) { - commitHash = repository.state.HEAD?.commit; - } - } - - if (commitHash) { - return commitHash; - } - - throw new Error(); - } - - protected async getUpstream(repository: Repository, commit: string): Promise { - return getBestPossibleUpstream(this.repositoryManager, repository, (await repository.getCommit(commit)).hash); - } -} - -export class GitHubPermalinkAsMarkdownShareProvider extends GitHubPermalinkShareProvider { - - constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { - super(repositoryManager, gitApi, 'githubComPermalinkAsMarkdown', vscode.l10n.t('Copy GitHub Permalink as Markdown'), 12); - } - - async provideShare(item: vscode.ShareableItem): Promise { - const link = await super.provideShare(item); - - const text = await this.getMarkdownLinkText(item); - if (link) { - return `[${text?.trim() ?? ''}](${link.toString()})`; - } - } - - private async getMarkdownLinkText(item: vscode.ShareableItem): Promise { - const fileName = pathLib.basename(item.resourceUri.path); - - if (item.selection) { - const document = await vscode.workspace.openTextDocument(item.resourceUri); - - const editorSelection = item.selection.start === item.selection.end - ? item.selection - : new vscode.Range(item.selection.start, new vscode.Position(item.selection.start.line + 1, 0)); - - const selectedText = document.getText(editorSelection); - if (selectedText) { - return selectedText; - } - - const wordRange = document.getWordRangeAtPosition(item.selection.start); - if (wordRange) { - return document.getText(wordRange); - } - } - - return fileName; - } -} - -export class GitHubHeadLinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { - constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { - super(repositoryManager, gitApi, 'githubComHeadLink', vscode.l10n.t('Copy GitHub HEAD Link'), 13); - } - - protected shouldRegister() { - return true; - } - - protected async getBlob(folderManager: FolderRepositoryManager): Promise { - return getHEAD(folderManager); - } - - protected async getUpstream(repository: Repository): Promise { - return getSimpleUpstream(repository); - } -} - -function getRangeSegment(item: vscode.ShareableItem): string { - if (item.resourceUri.scheme === 'vscode-notebook-cell') { - // Do not return a range or selection fragment for notebooks - // since github.com and github.dev do not support notebook deeplinks - return ''; - } - - return rangeString(item.selection); -} - -async function getHEAD(folderManager: FolderRepositoryManager) { - let branchName = folderManager.repository.state.HEAD?.name; - if (!branchName) { - // Fall back to default branch name if we are not currently on a branch - const origin = await folderManager.getOrigin(); - const metadata = await origin.getMetadata(); - branchName = metadata.default_branch; - } - - return encodeURIComponentExceptSlashes(branchName); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import { Commit, Remote, Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { fromReviewUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { encodeURIComponentExceptSlashes, getBestPossibleUpstream, getOwnerAndRepo, getSimpleUpstream, getUpstreamOrigin, rangeString } from './util'; + +export class ShareProviderManager implements vscode.Disposable { + private disposables: vscode.Disposable[] = []; + + constructor(repositoryManager: RepositoriesManager, gitAPI: GitApiImpl) { + if (!vscode.window.registerShareProvider) { + return; + } + + this.disposables.push( + new GitHubDevShareProvider(repositoryManager, gitAPI), + new GitHubPermalinkShareProvider(repositoryManager, gitAPI), + new GitHubPermalinkAsMarkdownShareProvider(repositoryManager, gitAPI), + new GitHubHeadLinkShareProvider(repositoryManager, gitAPI) + ); + } + + dispose() { + this.disposables.forEach((d) => d.dispose()); + } +} + +const supportedSchemes = [Schemes.File, Schemes.Review, Schemes.Pr, Schemes.VscodeVfs]; + +abstract class AbstractShareProvider implements vscode.Disposable, vscode.ShareProvider { + private disposables: vscode.Disposable[] = []; + protected shareProviderRegistrations: vscode.Disposable[] | undefined; + + constructor( + protected repositoryManager: RepositoriesManager, + protected gitAPI: GitApiImpl, + public readonly id: string, + public readonly label: string, + public readonly priority: number, + private readonly origin = 'github.com' + ) { + this.initialize(); + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()); + this.shareProviderRegistrations?.map((d) => d.dispose()); + } + + private async initialize() { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + + this.disposables.push(this.repositoryManager.onDidLoadAnyRepositories(async () => { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + })); + + this.disposables.push(this.gitAPI.onDidCloseRepository(() => { + if (!this.hasGitHubRepositories()) { + this.unregister(); + } + })); + } + + private async hasGitHubRepositories() { + for (const folderManager of this.repositoryManager.folderManagers) { + if ((await folderManager.computeAllGitHubRemotes()).length) { + return true; + } + return false; + } + } + + private register() { + if (this.shareProviderRegistrations) { + return; + } + + this.shareProviderRegistrations = supportedSchemes.map((scheme) => vscode.window.registerShareProvider({ scheme }, this)); + } + + private unregister() { + this.shareProviderRegistrations?.map((d) => d.dispose()); + this.shareProviderRegistrations = undefined; + } + + protected abstract shouldRegister(): boolean; + protected abstract getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise; + protected abstract getUpstream(repository: Repository, commit: string): Promise; + + public async provideShare(item: vscode.ShareableItem): Promise { + // Get the blob + const folderManager = this.repositoryManager.getManagerForFile(item.resourceUri); + if (!folderManager) { + throw new Error(vscode.l10n.t('Current file does not belong to an open repository.')); + } + const blob = await this.getBlob(folderManager, item.resourceUri); + + // Get the upstream + const repository = folderManager.repository; + const remote = await this.getUpstream(repository, blob); + if (!remote || !remote.fetchUrl) { + throw new Error(vscode.l10n.t('The selection may not exist on any remote.')); + } + + const origin = getUpstreamOrigin(remote, this.origin).replace(/\/$/, ''); + const path = encodeURIComponentExceptSlashes(item.resourceUri.path.substring(repository.rootUri.path.length)); + const range = getRangeSegment(item); + + return vscode.Uri.parse([ + origin, + '/', + getOwnerAndRepo(this.repositoryManager, repository, { ...remote, fetchUrl: remote.fetchUrl }), + '/blob/', + blob, + path, + range + ].join('')); + } +} + +export class GitHubDevShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubDevLink', vscode.l10n.t('Copy github.dev Link'), 10, 'github.dev'); + } + + protected shouldRegister(): boolean { + return vscode.env.appHost === 'github.dev'; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +export class GitHubPermalinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor( + repositoryManager: RepositoriesManager, + gitApi: GitApiImpl, + id: string = 'githubComPermalink', + label: string = vscode.l10n.t('Copy GitHub Permalink'), + priority: number = 11 + ) { + super(repositoryManager, gitApi, id, label, priority); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise { + let commit: Commit | undefined; + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } + + if (!commitHash) { + const repository = folderManager.repository; + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + throw new Error(vscode.l10n.t('No branch on a remote contains the most recent commit for the file.')); + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commit = await repository.getCommit(repository.state.HEAD.commit); + } + if (!commit) { + commit = log[0]; + } + commitHash = commit.hash; + } catch (e) { + commitHash = repository.state.HEAD?.commit; + } + } + + if (commitHash) { + return commitHash; + } + + throw new Error(); + } + + protected async getUpstream(repository: Repository, commit: string): Promise { + return getBestPossibleUpstream(this.repositoryManager, repository, (await repository.getCommit(commit)).hash); + } +} + +export class GitHubPermalinkAsMarkdownShareProvider extends GitHubPermalinkShareProvider { + + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComPermalinkAsMarkdown', vscode.l10n.t('Copy GitHub Permalink as Markdown'), 12); + } + + async provideShare(item: vscode.ShareableItem): Promise { + const link = await super.provideShare(item); + + const text = await this.getMarkdownLinkText(item); + if (link) { + return `[${text?.trim() ?? ''}](${link.toString()})`; + } + } + + private async getMarkdownLinkText(item: vscode.ShareableItem): Promise { + const fileName = pathLib.basename(item.resourceUri.path); + + if (item.selection) { + const document = await vscode.workspace.openTextDocument(item.resourceUri); + + const editorSelection = item.selection.start === item.selection.end + ? item.selection + : new vscode.Range(item.selection.start, new vscode.Position(item.selection.start.line + 1, 0)); + + const selectedText = document.getText(editorSelection); + if (selectedText) { + return selectedText; + } + + const wordRange = document.getWordRangeAtPosition(item.selection.start); + if (wordRange) { + return document.getText(wordRange); + } + } + + return fileName; + } +} + +export class GitHubHeadLinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComHeadLink', vscode.l10n.t('Copy GitHub HEAD Link'), 13); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +function getRangeSegment(item: vscode.ShareableItem): string { + if (item.resourceUri.scheme === 'vscode-notebook-cell') { + // Do not return a range or selection fragment for notebooks + // since github.com and github.dev do not support notebook deeplinks + return ''; + } + + return rangeString(item.selection); +} + +async function getHEAD(folderManager: FolderRepositoryManager) { + let branchName = folderManager.repository.state.HEAD?.name; + if (!branchName) { + // Fall back to default branch name if we are not currently on a branch + const origin = await folderManager.getOrigin(); + const metadata = await origin.getMetadata(); + branchName = metadata.default_branch; + } + + return encodeURIComponentExceptSlashes(branchName); +} diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index 2c0ba4335d..8723af03b2 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -1,544 +1,545 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import LRUCache from 'lru-cache'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { AuthProvider } from '../common/authentication'; -import { parseRepositoryRemotes } from '../common/remote'; -import { - DEFAULT, - IGNORE_MILESTONES, - ISSUES_SETTINGS_NAMESPACE, - QUERIES, - USE_BRANCH_FOR_ISSUES, -} from '../common/settingKeys'; -import { - FolderRepositoryManager, - NO_MILESTONE, - PullRequestDefaults, - ReposManagerState, -} from '../github/folderRepositoryManager'; -import { IAccount } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { MilestoneModel } from '../github/milestoneModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { CurrentIssue } from './currentIssue'; - -// TODO: make exclude from date words configurable -const excludeFromDate: string[] = ['Recovery']; -const CURRENT_ISSUE_KEY = 'currentIssue'; - -const ISSUES_KEY = 'issues'; - -export interface IssueState { - branch?: string; - hasDraftPR?: boolean; -} - -interface TimeStampedIssueState extends IssueState { - stateModifiedTime: number; -} - -interface IssuesState { - issues: Record; - branches: Record; -} - -const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: vscode.l10n.t('My Issues'), query: 'default' }]; - -export interface MilestoneItem extends MilestoneModel { - uri: vscode.Uri; -} - -export class IssueItem extends IssueModel { - uri: vscode.Uri; -} - -interface SingleRepoState { - lastHead?: string; - lastBranch?: string; - currentIssue?: CurrentIssue; - issueCollection: Map>; - maxIssueNumber: number; - userMap?: Promise>; - folderManager: FolderRepositoryManager; -} - -export class StateManager { - public readonly resolvedIssues: Map> = new Map(); - private _singleRepoStates: Map = new Map(); - private _onRefreshCacheNeeded: vscode.EventEmitter = new vscode.EventEmitter(); - public onRefreshCacheNeeded: vscode.Event = this._onRefreshCacheNeeded.event; - private _onDidChangeIssueData: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeIssueData: vscode.Event = this._onDidChangeIssueData.event; - private _queries: { label: string; query: string }[] = []; - - private _onDidChangeCurrentIssue: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidChangeCurrentIssue: vscode.Event = this._onDidChangeCurrentIssue.event; - private initializePromise: Promise | undefined; - private statusBarItem?: vscode.StatusBarItem; - - getIssueCollection(uri: vscode.Uri): Map> { - let collection = this._singleRepoStates.get(uri.path)?.issueCollection; - if (collection) { - return collection; - } else { - collection = new Map(); - return collection; - } - } - - constructor( - readonly gitAPI: GitApiImpl, - private manager: RepositoriesManager, - private context: vscode.ExtensionContext, - ) { } - - private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState { - let state = this._singleRepoStates.get(uri.path); - if (state) { - return state; - } - if (!folderManager) { - folderManager = this.manager.getManagerForFile(uri)!; - } - state = { - issueCollection: new Map(), - maxIssueNumber: 0, - folderManager, - }; - this._singleRepoStates.set(uri.path, state); - return state; - } - - async tryInitializeAndWait() { - if (!this.initializePromise) { - this.initializePromise = new Promise(resolve => { - if (!this.manager.credentialStore.isAnyAuthenticated()) { - // We don't wait for sign in to finish initializing. - const disposable = this.manager.credentialStore.onDidGetSession(() => { - disposable.dispose(); - this.doInitialize(); - }); - resolve(); - } else if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize().then(() => resolve()); - } else { - const disposable = this.manager.onDidChangeState(() => { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize().then(() => { - disposable.dispose(); - resolve(); - }); - } - }); - this.context.subscriptions.push(disposable); - } - }); - } - return this.initializePromise; - } - - private registerRepositoryChangeEvent() { - async function updateRepository(that: StateManager, repository: Repository) { - const state = that.getOrCreateSingleRepoState(repository.rootUri); - // setIssueData can cause the last head and branch state to change. Capture them before that can happen. - const oldHead = state.lastHead; - const oldBranch = state.lastBranch; - const newHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; - if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== oldHead) { - await that.setIssueData(state.folderManager); - } - - const newBranch = repository.state.HEAD?.name; - if ( - (oldHead !== newHead || oldBranch !== newBranch) && - (!state.currentIssue || newBranch !== state.currentIssue.branchName) - ) { - if (newBranch) { - if (state.folderManager) { - await that.setCurrentIssueFromBranch(state, newBranch, true); - } - } else { - await that.setCurrentIssue(state, undefined, true); - } - } - state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; - state.lastBranch = repository.state.HEAD ? repository.state.HEAD.name : undefined; - } - - function addChangeEvent(that: StateManager, repository: Repository) { - that.context.subscriptions.push( - repository.state.onDidChange(async () => { - updateRepository(that, repository); - }), - ); - } - - this.context.subscriptions.push(this.gitAPI.onDidOpenRepository(repository => { - updateRepository(this, repository); - addChangeEvent(this, repository); - })); - this.gitAPI.repositories.forEach(repository => { - addChangeEvent(this, repository); - }); - } - - refreshCacheNeeded() { - this._onRefreshCacheNeeded.fire(); - } - - async refresh() { - return this.setAllIssueData(); - } - - private async doInitialize() { - this.cleanIssueState(); - this._queries = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) - .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); - if (this._queries.length === 0) { - this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; - } - this.context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(change => { - if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { - this._queries = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) - .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); - this._onRefreshCacheNeeded.fire(); - } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { - this._onRefreshCacheNeeded.fire(); - } - }), - ); - this.registerRepositoryChangeEvent(); - await this.setAllIssueData(); - this.context.subscriptions.push( - this.onRefreshCacheNeeded(async () => { - await this.refresh(); - }), - ); - - for (const folderManager of this.manager.folderManagers) { - this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); - - const singleRepoState: SingleRepoState = this.getOrCreateSingleRepoState( - folderManager.repository.rootUri, - folderManager, - ); - singleRepoState.lastHead = folderManager.repository.state.HEAD - ? folderManager.repository.state.HEAD.commit - : undefined; - this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); - const branch = folderManager.repository.state.HEAD?.name; - if (!singleRepoState.currentIssue && branch) { - await this.setCurrentIssueFromBranch(singleRepoState, branch, true); - } - } - } - - private cleanIssueState() { - const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); - const state: IssuesState = stateString ? JSON.parse(stateString) : { issues: [], branches: [] }; - const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; - for (const issueState in state.issues) { - if (state.issues[issueState].stateModifiedTime < deleteDate) { - if (state.branches && state.branches[issueState]) { - delete state.branches[issueState]; - } - delete state.issues[issueState]; - } - } - } - - private async getUsers(uri: vscode.Uri): Promise> { - await this.initializePromise; - const assignableUsers = await this.manager.getManagerForFile(uri)?.getAssignableUsers(); - const userMap: Map = new Map(); - for (const remote in assignableUsers) { - assignableUsers[remote].forEach(account => { - userMap.set(account.login, account); - }); - } - return userMap; - } - - async getUserMap(uri: vscode.Uri): Promise> { - if (!this.initializePromise) { - return Promise.resolve(new Map()); - } - const state = this.getOrCreateSingleRepoState(uri); - if (!state.userMap || (await state.userMap).size === 0) { - state.userMap = this.getUsers(uri); - } - return state.userMap; - } - - private async getCurrentUser(authProviderId: AuthProvider): Promise { - return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; - } - - private async setAllIssueData() { - return Promise.all(this.manager.folderManagers.map(folderManager => this.setIssueData(folderManager))); - } - - private async setIssueData(folderManager: FolderRepositoryManager) { - const singleRepoState = this.getOrCreateSingleRepoState(folderManager.repository.rootUri, folderManager); - singleRepoState.issueCollection.clear(); - let defaults: PullRequestDefaults | undefined; - let user: string | undefined; - for (const query of this._queries) { - let items: Promise; - if (query.query === DEFAULT) { - items = this.setMilestones(folderManager); - } else { - if (!defaults) { - try { - defaults = await folderManager.getPullRequestDefaults(); - } catch (e) { - // leave defaults undefined - } - } - if (!user) { - const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( - remote => remote.isEnterprise - ); - user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); - } - items = this.setIssues( - folderManager, - // Do not resolve pull request defaults as they will get resolved in the query later per repository - await variableSubstitution(query.query, undefined, undefined, user), - ); - } - singleRepoState.issueCollection.set(query.label, items); - } - singleRepoState.maxIssueNumber = await folderManager.getMaxIssue(); - singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; - singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; - } - - private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { - return new Promise(async resolve => { - const issues = await folderManager.getIssues(query); - this._onDidChangeIssueData.fire(); - resolve( - issues.items.map(item => { - const issueItem: IssueItem = item as IssueItem; - issueItem.uri = folderManager.repository.rootUri; - return issueItem; - }), - ); - }); - } - - private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { - const createBranchConfig = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(USE_BRANCH_FOR_ISSUES); - if (createBranchConfig === 'off') { - return; - } - - let defaults: PullRequestDefaults | undefined; - try { - defaults = await singleRepoState.folderManager.getPullRequestDefaults(); - } catch (e) { - // No remote, don't try to set the current issue - return; - } - if (branchName === defaults.base) { - await this.setCurrentIssue(singleRepoState, undefined, false); - return; - } - - if (singleRepoState.currentIssue && singleRepoState.currentIssue.branchName === branchName) { - return; - } - - const state: IssuesState = this.getSavedState(); - for (const branch in state.branches) { - if (branch === branchName) { - const issueModel = await singleRepoState.folderManager.resolveIssue( - state.branches[branch].owner, - state.branches[branch].repositoryName, - state.branches[branch].number, - ); - if (issueModel) { - await this.setCurrentIssue( - singleRepoState, - new CurrentIssue(issueModel, singleRepoState.folderManager, this), - false, - silent - ); - } - return; - } - } - } - - private setMilestones(folderManager: FolderRepositoryManager): Promise { - return new Promise(async resolve => { - const now = new Date(); - const skipMilestones: string[] = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(IGNORE_MILESTONES, []); - const milestones = await folderManager.getMilestoneIssues( - { fetchNextPage: false }, - skipMilestones.indexOf(NO_MILESTONE) < 0, - ); - let mostRecentPastTitleTime: Date | undefined = undefined; - const milestoneDateMap: Map = new Map(); - const milestonesToUse: MilestoneItem[] = []; - - // The number of milestones is expected to be very low, so two passes through is negligible - for (let i = 0; i < milestones.items.length; i++) { - const item: MilestoneItem = milestones.items[i] as MilestoneItem; - item.uri = folderManager.repository.rootUri; - const milestone = milestones.items[i].milestone; - if ((item.issues && item.issues.length <= 0) || skipMilestones.indexOf(milestone.title) >= 0) { - continue; - } - - milestonesToUse.push(item); - let milestoneDate = milestone.dueOn ? new Date(milestone.dueOn) : undefined; - if (!milestoneDate) { - milestoneDate = new Date(this.removeDateExcludeStrings(milestone.title)); - if (isNaN(milestoneDate.getTime())) { - milestoneDate = new Date(milestone.createdAt!); - } - } - if ( - milestoneDate < now && - (mostRecentPastTitleTime === undefined || milestoneDate > mostRecentPastTitleTime) - ) { - mostRecentPastTitleTime = milestoneDate; - } - milestoneDateMap.set(milestone.id ? milestone.id : milestone.title, milestoneDate); - } - - milestonesToUse.sort((a: MilestoneModel, b: MilestoneModel): number => { - const dateA = milestoneDateMap.get(a.milestone.id ? a.milestone.id : a.milestone.title)!; - const dateB = milestoneDateMap.get(b.milestone.id ? b.milestone.id : b.milestone.title)!; - if (mostRecentPastTitleTime && dateA >= mostRecentPastTitleTime && dateB >= mostRecentPastTitleTime) { - return dateA <= dateB ? -1 : 1; - } else { - return dateA >= dateB ? -1 : 1; - } - }); - this._onDidChangeIssueData.fire(); - resolve(milestonesToUse); - }); - } - - private removeDateExcludeStrings(possibleDate: string): string { - excludeFromDate.forEach(exclude => (possibleDate = possibleDate.replace(exclude, ''))); - return possibleDate; - } - - currentIssue(uri: vscode.Uri): CurrentIssue | undefined { - return this._singleRepoStates.get(uri.path)?.currentIssue; - } - - currentIssues(): CurrentIssue[] { - return Array.from(this._singleRepoStates.values()) - .filter(state => state?.currentIssue) - .map(state => state!.currentIssue!); - } - - maxIssueNumber(uri: vscode.Uri): number { - return this._singleRepoStates.get(uri.path)?.maxIssueNumber ?? 0; - } - - private isSettingIssue: boolean = false; - async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { - if (this.isSettingIssue && issue === undefined) { - return; - } - this.isSettingIssue = true; - if (repoState instanceof FolderRepositoryManager) { - const state = this._singleRepoStates.get(repoState.repository.rootUri.path); - if (!state) { - return; - } - repoState = state; - } - try { - if (repoState.currentIssue && issue?.issue.number === repoState.currentIssue.issue.number) { - return; - } - if (repoState.currentIssue) { - await repoState.currentIssue.stopWorking(checkoutDefaultBranch); - } - if (issue) { - this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); - } - this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); - if (!issue || (await issue.startWorking(silent))) { - repoState.currentIssue = issue; - this.updateStatusBar(); - } - this._onDidChangeCurrentIssue.fire(); - } catch (e) { - // Error has already been surfaced - } finally { - this.isSettingIssue = false; - } - } - - private updateStatusBar() { - const currentIssues = this.currentIssues(); - const shouldShowStatusBarItem = currentIssues.length > 0; - if (!shouldShowStatusBarItem) { - if (this.statusBarItem) { - this.statusBarItem.hide(); - this.statusBarItem.dispose(); - this.statusBarItem = undefined; - } - return; - } - if (shouldShowStatusBarItem && !this.statusBarItem) { - this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); - this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); - } - const statusBarItem = this.statusBarItem!; - statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues - .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) - .join(', ')); - statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); - statusBarItem.command = 'issue.statusBar'; - statusBarItem.show(); - } - - private getSavedState(): IssuesState { - const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); - return stateString ? JSON.parse(stateString) : { issues: Object.create(null), branches: Object.create(null) }; - } - - getSavedIssueState(issueNumber: number): IssueState { - const state: IssuesState = this.getSavedState(); - return state.issues[`${issueNumber}`] ?? {}; - } - - async setSavedIssueState(issue: IssueModel, issueState: IssueState) { - const state: IssuesState = this.getSavedState(); - state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: new Date().valueOf() }; - if (issueState.branch) { - if (!state.branches) { - state.branches = Object.create(null); - } - state.branches[issueState.branch] = { - number: issue.number, - owner: issue.remote.owner, - repositoryName: issue.remote.repositoryName, - }; - } - return this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import LRUCache from 'lru-cache'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { AuthProvider } from '../common/authentication'; +import { parseRepositoryRemotes } from '../common/remote'; +import { + DEFAULT, + IGNORE_MILESTONES, + ISSUES_SETTINGS_NAMESPACE, + QUERIES, + USE_BRANCH_FOR_ISSUES, +} from '../common/settingKeys'; +import { + FolderRepositoryManager, + NO_MILESTONE, + PullRequestDefaults, + ReposManagerState, +} from '../github/folderRepositoryManager'; +import { IAccount } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { MilestoneModel } from '../github/milestoneModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; +import { CurrentIssue } from './currentIssue'; + +// TODO: make exclude from date words configurable +const excludeFromDate: string[] = ['Recovery']; +const CURRENT_ISSUE_KEY = 'currentIssue'; + +const ISSUES_KEY = 'issues'; + +export interface IssueState { + branch?: string; + hasDraftPR?: boolean; +} + +interface TimeStampedIssueState extends IssueState { + stateModifiedTime: number; +} + +interface IssuesState { + issues: Record; + branches: Record; +} + +const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: vscode.l10n.t('My Issues'), query: 'default' }]; + +export interface MilestoneItem extends MilestoneModel { + uri: vscode.Uri; +} + +export class IssueItem extends IssueModel { + uri: vscode.Uri; +} + +interface SingleRepoState { + lastHead?: string; + lastBranch?: string; + currentIssue?: CurrentIssue; + issueCollection: Map>; + maxIssueNumber: number; + userMap?: Promise>; + folderManager: FolderRepositoryManager; +} + +export class StateManager { + public readonly resolvedIssues: Map> = new Map(); + private _singleRepoStates: Map = new Map(); + private _onRefreshCacheNeeded: vscode.EventEmitter = new vscode.EventEmitter(); + public onRefreshCacheNeeded: vscode.Event = this._onRefreshCacheNeeded.event; + private _onDidChangeIssueData: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChangeIssueData: vscode.Event = this._onDidChangeIssueData.event; + private _queries: { label: string; query: string }[] = []; + + private _onDidChangeCurrentIssue: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeCurrentIssue: vscode.Event = this._onDidChangeCurrentIssue.event; + private initializePromise: Promise | undefined; + private statusBarItem?: vscode.StatusBarItem; + + getIssueCollection(uri: vscode.Uri): Map> { + let collection = this._singleRepoStates.get(uri.path)?.issueCollection; + if (collection) { + return collection; + } else { + collection = new Map(); + return collection; + } + } + + constructor( + readonly gitAPI: GitApiImpl, + private manager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { } + + private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState { + let state = this._singleRepoStates.get(uri.path); + if (state) { + return state; + } + if (!folderManager) { + folderManager = this.manager.getManagerForFile(uri)!; + } + state = { + issueCollection: new Map(), + maxIssueNumber: 0, + folderManager, + }; + this._singleRepoStates.set(uri.path, state); + return state; + } + + async tryInitializeAndWait() { + if (!this.initializePromise) { + this.initializePromise = new Promise(resolve => { + if (!this.manager.credentialStore.isAnyAuthenticated()) { + // We don't wait for sign in to finish initializing. + const disposable = this.manager.credentialStore.onDidGetSession(() => { + disposable.dispose(); + this.doInitialize(); + }); + resolve(); + } else if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + this.doInitialize().then(() => resolve()); + } else { + const disposable = this.manager.onDidChangeState(() => { + if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + this.doInitialize().then(() => { + disposable.dispose(); + resolve(); + }); + } + }); + this.context.subscriptions.push(disposable); + } + }); + } + return this.initializePromise; + } + + private registerRepositoryChangeEvent() { + async function updateRepository(that: StateManager, repository: Repository) { + const state = that.getOrCreateSingleRepoState(repository.rootUri); + // setIssueData can cause the last head and branch state to change. Capture them before that can happen. + const oldHead = state.lastHead; + const oldBranch = state.lastBranch; + const newHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== oldHead) { + await that.setIssueData(state.folderManager); + } + + const newBranch = repository.state.HEAD?.name; + if ( + (oldHead !== newHead || oldBranch !== newBranch) && + (!state.currentIssue || newBranch !== state.currentIssue.branchName) + ) { + if (newBranch) { + if (state.folderManager) { + await that.setCurrentIssueFromBranch(state, newBranch, true); + } + } else { + await that.setCurrentIssue(state, undefined, true); + } + } + state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + state.lastBranch = repository.state.HEAD ? repository.state.HEAD.name : undefined; + } + + function addChangeEvent(that: StateManager, repository: Repository) { + that.context.subscriptions.push( + repository.state.onDidChange(async () => { + updateRepository(that, repository); + }), + ); + } + + this.context.subscriptions.push(this.gitAPI.onDidOpenRepository(repository => { + updateRepository(this, repository); + addChangeEvent(this, repository); + })); + this.gitAPI.repositories.forEach(repository => { + addChangeEvent(this, repository); + }); + } + + refreshCacheNeeded() { + this._onRefreshCacheNeeded.fire(); + } + + async refresh() { + return this.setAllIssueData(); + } + + private async doInitialize() { + this.cleanIssueState(); + this._queries = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); + if (this._queries.length === 0) { + this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; + } + this.context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(change => { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this._queries = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); + this._onRefreshCacheNeeded.fire(); + } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { + this._onRefreshCacheNeeded.fire(); + } + }), + ); + this.registerRepositoryChangeEvent(); + await this.setAllIssueData(); + this.context.subscriptions.push( + this.onRefreshCacheNeeded(async () => { + await this.refresh(); + }), + ); + + for (const folderManager of this.manager.folderManagers) { + this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); + + const singleRepoState: SingleRepoState = this.getOrCreateSingleRepoState( + folderManager.repository.rootUri, + folderManager, + ); + singleRepoState.lastHead = folderManager.repository.state.HEAD + ? folderManager.repository.state.HEAD.commit + : undefined; + this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); + const branch = folderManager.repository.state.HEAD?.name; + if (!singleRepoState.currentIssue && branch) { + await this.setCurrentIssueFromBranch(singleRepoState, branch, true); + } + } + } + + private cleanIssueState() { + const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); + const state: IssuesState = stateString ? JSON.parse(stateString) : { issues: [], branches: [] }; + const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; + for (const issueState in state.issues) { + if (state.issues[issueState].stateModifiedTime < deleteDate) { + if (state.branches && state.branches[issueState]) { + delete state.branches[issueState]; + } + delete state.issues[issueState]; + } + } + } + + private async getUsers(uri: vscode.Uri): Promise> { + await this.initializePromise; + const assignableUsers = await this.manager.getManagerForFile(uri)?.getAssignableUsers(); + const userMap: Map = new Map(); + for (const remote in assignableUsers) { + assignableUsers[remote].forEach(account => { + userMap.set(account.login, account); + }); + } + return userMap; + } + + async getUserMap(uri: vscode.Uri): Promise> { + if (!this.initializePromise) { + return Promise.resolve(new Map()); + } + const state = this.getOrCreateSingleRepoState(uri); + if (!state.userMap || (await state.userMap).size === 0) { + state.userMap = this.getUsers(uri); + } + return state.userMap; + } + + private async getCurrentUser(authProviderId: AuthProvider): Promise { + return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; + } + + private async setAllIssueData() { + return Promise.all(this.manager.folderManagers.map(folderManager => this.setIssueData(folderManager))); + } + + private async setIssueData(folderManager: FolderRepositoryManager) { + const singleRepoState = this.getOrCreateSingleRepoState(folderManager.repository.rootUri, folderManager); + singleRepoState.issueCollection.clear(); + let defaults: PullRequestDefaults | undefined; + let user: string | undefined; + for (const query of this._queries) { + let items: Promise; + if (query.query === DEFAULT) { + items = this.setMilestones(folderManager); + } else { + if (!defaults) { + try { + defaults = await folderManager.getPullRequestDefaults(); + } catch (e) { + // leave defaults undefined + } + } + if (!user) { + const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( + remote => remote.isEnterprise + ); + user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); + } + items = this.setIssues( + folderManager, + // Do not resolve pull request defaults as they will get resolved in the query later per repository + await variableSubstitution(query.query, undefined, undefined, user), + ); + } + singleRepoState.issueCollection.set(query.label, items); + } + singleRepoState.maxIssueNumber = await folderManager.getMaxIssue(); + singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; + singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; + } + + private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { + return new Promise(async resolve => { + const issues = await folderManager.getIssues(query); + this._onDidChangeIssueData.fire(); + resolve( + issues.items.map(item => { + const issueItem: IssueItem = item as IssueItem; + issueItem.uri = folderManager.repository.rootUri; + return issueItem; + }), + ); + }); + } + + private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { + const createBranchConfig = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(USE_BRANCH_FOR_ISSUES); + if (createBranchConfig === 'off') { + return; + } + + let defaults: PullRequestDefaults | undefined; + try { + defaults = await singleRepoState.folderManager.getPullRequestDefaults(); + } catch (e) { + // No remote, don't try to set the current issue + return; + } + if (branchName === defaults.base) { + await this.setCurrentIssue(singleRepoState, undefined, false); + return; + } + + if (singleRepoState.currentIssue && singleRepoState.currentIssue.branchName === branchName) { + return; + } + + const state: IssuesState = this.getSavedState(); + for (const branch in state.branches) { + if (branch === branchName) { + const issueModel = await singleRepoState.folderManager.resolveIssue( + state.branches[branch].owner, + state.branches[branch].repositoryName, + state.branches[branch].number, + ); + if (issueModel) { + await this.setCurrentIssue( + singleRepoState, + new CurrentIssue(issueModel, singleRepoState.folderManager, this), + false, + silent + ); + } + return; + } + } + } + + private setMilestones(folderManager: FolderRepositoryManager): Promise { + return new Promise(async resolve => { + const now = new Date(); + const skipMilestones: string[] = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_MILESTONES, []); + const milestones = await folderManager.getMilestoneIssues( + { fetchNextPage: false }, + skipMilestones.indexOf(NO_MILESTONE) < 0, + ); + let mostRecentPastTitleTime: Date | undefined = undefined; + const milestoneDateMap: Map = new Map(); + const milestonesToUse: MilestoneItem[] = []; + + // The number of milestones is expected to be very low, so two passes through is negligible + for (let i = 0; i < milestones.items.length; i++) { + const item: MilestoneItem = milestones.items[i] as MilestoneItem; + item.uri = folderManager.repository.rootUri; + const milestone = milestones.items[i].milestone; + if ((item.issues && item.issues.length <= 0) || skipMilestones.indexOf(milestone.title) >= 0) { + continue; + } + + milestonesToUse.push(item); + let milestoneDate = milestone.dueOn ? new Date(milestone.dueOn) : undefined; + if (!milestoneDate) { + milestoneDate = new Date(this.removeDateExcludeStrings(milestone.title)); + if (isNaN(milestoneDate.getTime())) { + milestoneDate = new Date(milestone.createdAt!); + } + } + if ( + milestoneDate < now && + (mostRecentPastTitleTime === undefined || milestoneDate > mostRecentPastTitleTime) + ) { + mostRecentPastTitleTime = milestoneDate; + } + milestoneDateMap.set(milestone.id ? milestone.id : milestone.title, milestoneDate); + } + + milestonesToUse.sort((a: MilestoneModel, b: MilestoneModel): number => { + const dateA = milestoneDateMap.get(a.milestone.id ? a.milestone.id : a.milestone.title)!; + const dateB = milestoneDateMap.get(b.milestone.id ? b.milestone.id : b.milestone.title)!; + if (mostRecentPastTitleTime && dateA >= mostRecentPastTitleTime && dateB >= mostRecentPastTitleTime) { + return dateA <= dateB ? -1 : 1; + } else { + return dateA >= dateB ? -1 : 1; + } + }); + this._onDidChangeIssueData.fire(); + resolve(milestonesToUse); + }); + } + + private removeDateExcludeStrings(possibleDate: string): string { + excludeFromDate.forEach(exclude => (possibleDate = possibleDate.replace(exclude, ''))); + return possibleDate; + } + + currentIssue(uri: vscode.Uri): CurrentIssue | undefined { + return this._singleRepoStates.get(uri.path)?.currentIssue; + } + + currentIssues(): CurrentIssue[] { + return Array.from(this._singleRepoStates.values()) + .filter(state => state?.currentIssue) + .map(state => state!.currentIssue!); + } + + maxIssueNumber(uri: vscode.Uri): number { + return this._singleRepoStates.get(uri.path)?.maxIssueNumber ?? 0; + } + + private isSettingIssue: boolean = false; + async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { + if (this.isSettingIssue && issue === undefined) { + return; + } + this.isSettingIssue = true; + if (repoState instanceof FolderRepositoryManager) { + const state = this._singleRepoStates.get(repoState.repository.rootUri.path); + if (!state) { + return; + } + repoState = state; + } + try { + if (repoState.currentIssue && issue?.issue.number === repoState.currentIssue.issue.number) { + return; + } + if (repoState.currentIssue) { + await repoState.currentIssue.stopWorking(checkoutDefaultBranch); + } + if (issue) { + this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); + } + this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); + if (!issue || (await issue.startWorking(silent))) { + repoState.currentIssue = issue; + this.updateStatusBar(); + } + this._onDidChangeCurrentIssue.fire(); + } catch (e) { + // Error has already been surfaced + } finally { + this.isSettingIssue = false; + } + } + + private updateStatusBar() { + const currentIssues = this.currentIssues(); + const shouldShowStatusBarItem = currentIssues.length > 0; + if (!shouldShowStatusBarItem) { + if (this.statusBarItem) { + this.statusBarItem.hide(); + this.statusBarItem.dispose(); + this.statusBarItem = undefined; + } + return; + } + if (shouldShowStatusBarItem && !this.statusBarItem) { + this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); + this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); + } + const statusBarItem = this.statusBarItem!; + statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues + .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) + .join(', ')); + statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); + statusBarItem.command = 'issue.statusBar'; + statusBarItem.show(); + } + + private getSavedState(): IssuesState { + const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); + return stateString ? JSON.parse(stateString) : { issues: Object.create(null), branches: Object.create(null) }; + } + + getSavedIssueState(issueNumber: number): IssueState { + const state: IssuesState = this.getSavedState(); + return state.issues[`${issueNumber}`] ?? {}; + } + + async setSavedIssueState(issue: IssueModel, issueState: IssueState) { + const state: IssuesState = this.getSavedState(); + state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: new Date().valueOf() }; + if (issueState.branch) { + if (!state.branches) { + state.branches = Object.create(null); + } + state.branches[issueState.branch] = { + number: issue.number, + owner: issue.remote.owner, + repositoryName: issue.remote.repositoryName, + }; + } + return this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); + } +} diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index 5188e80b18..3da100d253 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -1,334 +1,335 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import Logger from '../common/logger'; -import { IGNORE_USER_COMPLETION_TRIGGER, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { TimelineEvent } from '../common/timelineEvent'; -import { fromPRUri, Schemes } from '../common/uri'; -import { compareIgnoreCase } from '../common/utils'; -import { EXTENSION_ID } from '../constants'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { IAccount, User } from '../github/interface'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getRelatedUsersFromTimelineEvents } from '../github/utils'; -import { ASSIGNEES, extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; -import { StateManager } from './stateManager'; -import { getRootUriFromScmInputUri, isComment, UserCompletion, userMarkdown } from './util'; - -export class UserCompletionProvider implements vscode.CompletionItemProvider { - private static readonly ID: string = 'UserCompletionProvider'; - private _gitBlameCache: { [key: string]: string } = {}; - - constructor( - private stateManager: StateManager, - private manager: RepositoriesManager, - _context: vscode.ExtensionContext, - ) { } - - async provideCompletionItems( - document: vscode.TextDocument, - position: vscode.Position, - token: vscode.CancellationToken, - context: vscode.CompletionContext, - ): Promise { - let wordRange = document.getWordRangeAtPosition(position); - let wordAtPos = wordRange ? document.getText(wordRange) : undefined; - if (!wordRange || wordAtPos?.charAt(0) !== '@') { - const start = wordRange?.start ?? position; - const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); - const testWord = document.getText(testWordRange); - if (testWord.charAt(0) === '@') { - wordRange = testWordRange; - wordAtPos = testWord; - } - } - // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character - if ( - document.languageId !== 'scminput' && - document.uri.scheme !== NEW_ISSUE_SCHEME && - position.character > 0 && - context.triggerKind === vscode.CompletionTriggerKind.Invoke && - wordAtPos?.charAt(0) !== '@' - ) { - return []; - } - - // If the suggest was not triggered by the trigger character and it's in a new issue file, make sure it's on the Assignees line. - if ( - (document.uri.scheme === NEW_ISSUE_SCHEME) && - (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && - (document.getText(new vscode.Range(position.with(undefined, 0), position.with(undefined, ASSIGNEES.length))) !== ASSIGNEES) - ) { - return []; - } - - if ( - context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && - vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(IGNORE_USER_COMPLETION_TRIGGER, []) - .find(value => value === document.languageId) - ) { - return []; - } - - if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { - return []; - } - - let range: vscode.Range = new vscode.Range(position, position); - if (position.character - 1 >= 0) { - if (wordRange && wordAtPos?.charAt(0) === '@') { - range = wordRange; - } - } - - let uri: vscode.Uri | undefined = document.uri; - if (document.uri.scheme === NEW_ISSUE_SCHEME) { - uri = extractIssueOriginFromQuery(document.uri) ?? document.uri; - } else if (document.languageId === 'scminput') { - uri = getRootUriFromScmInputUri(document.uri); - } else if (document.uri.scheme === Schemes.Comment) { - const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - uri = activeTab instanceof vscode.TabInputText ? activeTab.uri : (activeTab instanceof vscode.TabInputTextDiff ? activeTab.modified : undefined); - } - - if (!uri) { - return []; - } - - const repoUri = this.manager.getManagerForFile(uri)?.repository.rootUri ?? uri; - - let completionItems: vscode.CompletionItem[] = []; - const userMap = await this.stateManager.getUserMap(repoUri); - userMap.forEach(item => { - const completionItem: UserCompletion = new UserCompletion( - { label: item.login, description: item.name }, vscode.CompletionItemKind.User); - completionItem.insertText = `@${item.login}`; - completionItem.login = item.login; - completionItem.uri = repoUri; - completionItem.range = range; - completionItem.detail = item.name; - completionItem.filterText = `@ ${item.login} ${item.name}`; - if (document.uri.scheme === NEW_ISSUE_SCHEME) { - completionItem.commitCharacters = [' ', ',']; - } - completionItems.push(completionItem); - }); - const commentSpecificSuggestions = await this.getCommentSpecificSuggestions(userMap, document, position); - if (commentSpecificSuggestions) { - completionItems = completionItems.concat(commentSpecificSuggestions); - } - return completionItems; - } - - private isCodeownersFiles(uri: vscode.Uri): boolean { - const repositoryManager = this.manager.getManagerForFile(uri); - if (!repositoryManager || !uri.path.startsWith(repositoryManager.repository.rootUri.path)) { - return false; - } - const subpath = uri.path.substring(repositoryManager.repository.rootUri.path.length).toLowerCase(); - const codeownersFiles = ['/codeowners', '/docs/codeowners', '/.github/codeowners']; - return !!codeownersFiles.find(file => file === subpath); - } - - async resolveCompletionItem(item: UserCompletion, _token: vscode.CancellationToken): Promise { - const folderManager = this.manager.getManagerForFile(item.uri); - if (!folderManager) { - return item; - } - const repo = await folderManager.getPullRequestDefaults(); - const user: User | undefined = await folderManager.resolveUser(repo.owner, repo.repo, item.login); - if (user) { - item.documentation = userMarkdown(repo, user); - item.command = { - command: 'issues.userCompletion', - title: vscode.l10n.t('User Completion Chosen'), - }; - } - return item; - } - - private cachedPrUsers: UserCompletion[] = []; - private cachedPrTimelineEvents: TimelineEvent[] = []; - private cachedForPrNumber: number | undefined; - private async getCommentSpecificSuggestions( - alreadyIncludedUsers: Map, - document: vscode.TextDocument, - position: vscode.Position) { - try { - const query = JSON.parse(document.uri.query); - if ((document.uri.scheme !== Schemes.Comment) || compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { - return; - } - - const wordRange = document.getWordRangeAtPosition( - position, - /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, - ); - if (!wordRange || wordRange.isEmpty) { - return; - } - const activeTextEditors = vscode.window.visibleTextEditors; - if (!activeTextEditors.length) { - return; - } - - let foundRepositoryManager: FolderRepositoryManager | undefined; - - let activeTextEditor: vscode.TextEditor | undefined; - let prNumber: number | undefined; - let remoteName: string | undefined; - - for (const editor of activeTextEditors) { - foundRepositoryManager = this.manager.getManagerForFile(editor.document.uri); - if (foundRepositoryManager) { - if (foundRepositoryManager.activePullRequest) { - prNumber = foundRepositoryManager.activePullRequest.number; - remoteName = foundRepositoryManager.activePullRequest.remote.remoteName; - break; - } else if (editor.document.uri.scheme === Schemes.Pr) { - const params = fromPRUri(editor.document.uri); - prNumber = params!.prNumber; - remoteName = params!.remoteName; - break; - } - } - } - - if (!foundRepositoryManager) { - return; - } - const repositoryManager = foundRepositoryManager; - - if (prNumber && prNumber === this.cachedForPrNumber) { - return this.cachedPrUsers; - } - - let prRelatedusers: { login: string; name?: string }[] = []; - const fileRelatedUsersNames: { [key: string]: boolean } = {}; - let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; - - const prRelatedUsersPromise = new Promise(async resolve => { - if (prNumber && remoteName) { - Logger.debug('get Timeline Events and parse users', UserCompletionProvider.ID); - if (this.cachedForPrNumber === prNumber) { - return this.cachedPrTimelineEvents; - } - - const githubRepo = repositoryManager.gitHubRepositories.find( - repo => repo.remote.remoteName === remoteName, - ); - - if (githubRepo) { - const pr = await githubRepo.getPullRequest(prNumber); - this.cachedForPrNumber = prNumber; - this.cachedPrTimelineEvents = await pr!.getTimelineEvents(); - } - - prRelatedusers = getRelatedUsersFromTimelineEvents(this.cachedPrTimelineEvents); - resolve(); - } - - resolve(); - }); - - const fileRelatedUsersNamesPromise = new Promise(async resolve => { - if (activeTextEditors.length) { - try { - Logger.debug('git blame and parse users', UserCompletionProvider.ID); - const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); - let blames: string | undefined; - if (this._gitBlameCache[fsPath]) { - blames = this._gitBlameCache[fsPath]; - } else { - blames = await repositoryManager.repository.blame(fsPath); - this._gitBlameCache[fsPath] = blames; - } - - const blameLines = blames.split('\n'); - - for (const line of blameLines) { - const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); - - if (matches && matches.length === 2) { - const name = matches[1].trim(); - fileRelatedUsersNames[name] = true; - } - } - } catch (err) { - Logger.debug(err, UserCompletionProvider.ID); - } - } - - resolve(); - }); - - const getMentionableUsersPromise = new Promise(async resolve => { - Logger.debug('get mentionable users', UserCompletionProvider.ID); - mentionableUsers = await repositoryManager.getMentionableUsers(); - resolve(); - }); - - await Promise.all([ - prRelatedUsersPromise, - fileRelatedUsersNamesPromise, - getMentionableUsersPromise, - ]); - - this.cachedPrUsers = []; - const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; - Logger.debug('prepare user suggestions', UserCompletionProvider.ID); - - prRelatedusers.forEach(user => { - if (!prRelatedUsersMap[user.login]) { - prRelatedUsersMap[user.login] = user; - } - }); - - const secondMap: { [key: string]: boolean } = {}; - - for (const mentionableUserGroup in mentionableUsers) { - for (const user of mentionableUsers[mentionableUserGroup]) { - if (!prRelatedUsersMap[user.login] && !secondMap[user.login] && !alreadyIncludedUsers.get(user.login)) { - secondMap[user.login] = true; - - let priority = 2; - if ( - fileRelatedUsersNames[user.login] || - (user.name && fileRelatedUsersNames[user.name]) - ) { - priority = 1; - } - - if (prRelatedUsersMap[user.login]) { - priority = 0; - } - - const completionItem: UserCompletion = new UserCompletion( - { label: user.login, description: user.name }, vscode.CompletionItemKind.User); - completionItem.insertText = `@${user.login}`; - completionItem.login = user.login; - completionItem.uri = repositoryManager.repository.rootUri; - completionItem.detail = user.name; - completionItem.filterText = `@ ${user.login} ${user.name}`; - completionItem.sortText = `${priority}_${user.login}`; - if (activeTextEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { - completionItem.commitCharacters = [' ', ',']; - } - this.cachedPrUsers.push(completionItem); - } - } - } - - Logger.debug('done', UserCompletionProvider.ID); - return this.cachedPrUsers; - } catch (e) { - return []; - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { IGNORE_USER_COMPLETION_TRIGGER, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { TimelineEvent } from '../common/timelineEvent'; +import { fromPRUri, Schemes } from '../common/uri'; +import { compareIgnoreCase } from '../common/utils'; +import { EXTENSION_ID } from '../constants'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { IAccount, User } from '../github/interface'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getRelatedUsersFromTimelineEvents } from '../github/utils'; +import { ASSIGNEES, extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; +import { StateManager } from './stateManager'; +import { getRootUriFromScmInputUri, isComment, UserCompletion, userMarkdown } from './util'; + +export class UserCompletionProvider implements vscode.CompletionItemProvider { + private static readonly ID: string = 'UserCompletionProvider'; + private _gitBlameCache: { [key: string]: string } = {}; + + constructor( + private stateManager: StateManager, + private manager: RepositoriesManager, + _context: vscode.ExtensionContext, + ) { } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext, + ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '@') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '@') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } + // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character + if ( + document.languageId !== 'scminput' && + document.uri.scheme !== NEW_ISSUE_SCHEME && + position.character > 0 && + context.triggerKind === vscode.CompletionTriggerKind.Invoke && + wordAtPos?.charAt(0) !== '@' + ) { + return []; + } + + // If the suggest was not triggered by the trigger character and it's in a new issue file, make sure it's on the Assignees line. + if ( + (document.uri.scheme === NEW_ISSUE_SCHEME) && + (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && + (document.getText(new vscode.Range(position.with(undefined, 0), position.with(undefined, ASSIGNEES.length))) !== ASSIGNEES) + ) { + return []; + } + + if ( + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && + vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_USER_COMPLETION_TRIGGER, []) + .find(value => value === document.languageId) + ) { + return []; + } + + if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + return []; + } + + let range: vscode.Range = new vscode.Range(position, position); + if (position.character - 1 >= 0) { + if (wordRange && wordAtPos?.charAt(0) === '@') { + range = wordRange; + } + } + + let uri: vscode.Uri | undefined = document.uri; + if (document.uri.scheme === NEW_ISSUE_SCHEME) { + uri = extractIssueOriginFromQuery(document.uri) ?? document.uri; + } else if (document.languageId === 'scminput') { + uri = getRootUriFromScmInputUri(document.uri); + } else if (document.uri.scheme === Schemes.Comment) { + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + uri = activeTab instanceof vscode.TabInputText ? activeTab.uri : (activeTab instanceof vscode.TabInputTextDiff ? activeTab.modified : undefined); + } + + if (!uri) { + return []; + } + + const repoUri = this.manager.getManagerForFile(uri)?.repository.rootUri ?? uri; + + let completionItems: vscode.CompletionItem[] = []; + const userMap = await this.stateManager.getUserMap(repoUri); + userMap.forEach(item => { + const completionItem: UserCompletion = new UserCompletion( + { label: item.login, description: item.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${item.login}`; + completionItem.login = item.login; + completionItem.uri = repoUri; + completionItem.range = range; + completionItem.detail = item.name; + completionItem.filterText = `@ ${item.login} ${item.name}`; + if (document.uri.scheme === NEW_ISSUE_SCHEME) { + completionItem.commitCharacters = [' ', ',']; + } + completionItems.push(completionItem); + }); + const commentSpecificSuggestions = await this.getCommentSpecificSuggestions(userMap, document, position); + if (commentSpecificSuggestions) { + completionItems = completionItems.concat(commentSpecificSuggestions); + } + return completionItems; + } + + private isCodeownersFiles(uri: vscode.Uri): boolean { + const repositoryManager = this.manager.getManagerForFile(uri); + if (!repositoryManager || !uri.path.startsWith(repositoryManager.repository.rootUri.path)) { + return false; + } + const subpath = uri.path.substring(repositoryManager.repository.rootUri.path.length).toLowerCase(); + const codeownersFiles = ['/codeowners', '/docs/codeowners', '/.github/codeowners']; + return !!codeownersFiles.find(file => file === subpath); + } + + async resolveCompletionItem(item: UserCompletion, _token: vscode.CancellationToken): Promise { + const folderManager = this.manager.getManagerForFile(item.uri); + if (!folderManager) { + return item; + } + const repo = await folderManager.getPullRequestDefaults(); + const user: User | undefined = await folderManager.resolveUser(repo.owner, repo.repo, item.login); + if (user) { + item.documentation = userMarkdown(repo, user); + item.command = { + command: 'issues.userCompletion', + title: vscode.l10n.t('User Completion Chosen'), + }; + } + return item; + } + + private cachedPrUsers: UserCompletion[] = []; + private cachedPrTimelineEvents: TimelineEvent[] = []; + private cachedForPrNumber: number | undefined; + private async getCommentSpecificSuggestions( + alreadyIncludedUsers: Map, + document: vscode.TextDocument, + position: vscode.Position) { + try { + const query = JSON.parse(document.uri.query); + if ((document.uri.scheme !== Schemes.Comment) || compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { + return; + } + + const wordRange = document.getWordRangeAtPosition( + position, + /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, + ); + if (!wordRange || wordRange.isEmpty) { + return; + } + const activeTextEditors = vscode.window.visibleTextEditors; + if (!activeTextEditors.length) { + return; + } + + let foundRepositoryManager: FolderRepositoryManager | undefined; + + let activeTextEditor: vscode.TextEditor | undefined; + let prNumber: number | undefined; + let remoteName: string | undefined; + + for (const editor of activeTextEditors) { + foundRepositoryManager = this.manager.getManagerForFile(editor.document.uri); + if (foundRepositoryManager) { + if (foundRepositoryManager.activePullRequest) { + prNumber = foundRepositoryManager.activePullRequest.number; + remoteName = foundRepositoryManager.activePullRequest.remote.remoteName; + break; + } else if (editor.document.uri.scheme === Schemes.Pr) { + const params = fromPRUri(editor.document.uri); + prNumber = params!.prNumber; + remoteName = params!.remoteName; + break; + } + } + } + + if (!foundRepositoryManager) { + return; + } + const repositoryManager = foundRepositoryManager; + + if (prNumber && prNumber === this.cachedForPrNumber) { + return this.cachedPrUsers; + } + + let prRelatedusers: { login: string; name?: string }[] = []; + const fileRelatedUsersNames: { [key: string]: boolean } = {}; + let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; + + const prRelatedUsersPromise = new Promise(async resolve => { + if (prNumber && remoteName) { + Logger.debug('get Timeline Events and parse users', UserCompletionProvider.ID); + if (this.cachedForPrNumber === prNumber) { + return this.cachedPrTimelineEvents; + } + + const githubRepo = repositoryManager.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + if (githubRepo) { + const pr = await githubRepo.getPullRequest(prNumber); + this.cachedForPrNumber = prNumber; + this.cachedPrTimelineEvents = await pr!.getTimelineEvents(); + } + + prRelatedusers = getRelatedUsersFromTimelineEvents(this.cachedPrTimelineEvents); + resolve(); + } + + resolve(); + }); + + const fileRelatedUsersNamesPromise = new Promise(async resolve => { + if (activeTextEditors.length) { + try { + Logger.debug('git blame and parse users', UserCompletionProvider.ID); + const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); + let blames: string | undefined; + if (this._gitBlameCache[fsPath]) { + blames = this._gitBlameCache[fsPath]; + } else { + blames = await repositoryManager.repository.blame(fsPath); + this._gitBlameCache[fsPath] = blames; + } + + const blameLines = blames.split('\n'); + + for (const line of blameLines) { + const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); + + if (matches && matches.length === 2) { + const name = matches[1].trim(); + fileRelatedUsersNames[name] = true; + } + } + } catch (err) { + Logger.debug(err, UserCompletionProvider.ID); + } + } + + resolve(); + }); + + const getMentionableUsersPromise = new Promise(async resolve => { + Logger.debug('get mentionable users', UserCompletionProvider.ID); + mentionableUsers = await repositoryManager.getMentionableUsers(); + resolve(); + }); + + await Promise.all([ + prRelatedUsersPromise, + fileRelatedUsersNamesPromise, + getMentionableUsersPromise, + ]); + + this.cachedPrUsers = []; + const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; + Logger.debug('prepare user suggestions', UserCompletionProvider.ID); + + prRelatedusers.forEach(user => { + if (!prRelatedUsersMap[user.login]) { + prRelatedUsersMap[user.login] = user; + } + }); + + const secondMap: { [key: string]: boolean } = {}; + + for (const mentionableUserGroup in mentionableUsers) { + for (const user of mentionableUsers[mentionableUserGroup]) { + if (!prRelatedUsersMap[user.login] && !secondMap[user.login] && !alreadyIncludedUsers.get(user.login)) { + secondMap[user.login] = true; + + let priority = 2; + if ( + fileRelatedUsersNames[user.login] || + (user.name && fileRelatedUsersNames[user.name]) + ) { + priority = 1; + } + + if (prRelatedUsersMap[user.login]) { + priority = 0; + } + + const completionItem: UserCompletion = new UserCompletion( + { label: user.login, description: user.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${user.login}`; + completionItem.login = user.login; + completionItem.uri = repositoryManager.repository.rootUri; + completionItem.detail = user.name; + completionItem.filterText = `@ ${user.login} ${user.name}`; + completionItem.sortText = `${priority}_${user.login}`; + if (activeTextEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { + completionItem.commitCharacters = [' ', ',']; + } + this.cachedPrUsers.push(completionItem); + } + } + } + + Logger.debug('done', UserCompletionProvider.ID); + return this.cachedPrUsers; + } catch (e) { + return []; + } + } +} diff --git a/src/issues/userHoverProvider.ts b/src/issues/userHoverProvider.ts index 9252ff8d14..8a65f09718 100644 --- a/src/issues/userHoverProvider.ts +++ b/src/issues/userHoverProvider.ts @@ -1,76 +1,77 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { ITelemetry } from '../common/telemetry'; -import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { shouldShowHover, USER_EXPRESSION, userMarkdown } from './util'; - -export class UserHoverProvider implements vscode.HoverProvider { - constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } - - async provideHover( - document: vscode.TextDocument, - position: vscode.Position, - _token: vscode.CancellationToken, - ): Promise { - if (!(await shouldShowHover(document, position))) { - return; - } - - let wordPosition = document.getWordRangeAtPosition(position, USER_EXPRESSION); - if (wordPosition && wordPosition.start.character > 0) { - wordPosition = new vscode.Range( - new vscode.Position(wordPosition.start.line, wordPosition.start.character), - wordPosition.end, - ); - const word = document.getText(wordPosition); - const match = word.match(USER_EXPRESSION); - if (match) { - const username = match[1]; - // JS and TS doc checks - if (((document.languageId === 'javascript') || (document.languageId === 'typescript')) - && JSDOC_NON_USERS.indexOf(username) >= 0) { - return; - } - // PHP doc checks - if ((document.languageId === 'php') && PHPDOC_NON_USERS.indexOf(username) >= 0) { - return; - } - return this.createHover(document.uri, username, wordPosition); - } - } else { - return; - } - } - - private async createHover( - uri: vscode.Uri, - username: string, - range: vscode.Range, - ): Promise { - try { - const folderManager = this.manager.getManagerForFile(uri); - if (!folderManager) { - return; - } - const origin = await folderManager.getPullRequestDefaults(); - const user = await folderManager.resolveUser(origin.owner, origin.repo, username); - if (user && user.name) { - /* __GDPR__ - "issue.userHover" : {} - */ - this.telemetry.sendTelemetryEvent('issues.userHover'); - return new vscode.Hover(userMarkdown(origin, user), range); - } else { - return; - } - } catch (e) { - // No need to notify about a hover that doesn't work - return; - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { ITelemetry } from '../common/telemetry'; +import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { shouldShowHover, USER_EXPRESSION, userMarkdown } from './util'; + +export class UserHoverProvider implements vscode.HoverProvider { + constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + ): Promise { + if (!(await shouldShowHover(document, position))) { + return; + } + + let wordPosition = document.getWordRangeAtPosition(position, USER_EXPRESSION); + if (wordPosition && wordPosition.start.character > 0) { + wordPosition = new vscode.Range( + new vscode.Position(wordPosition.start.line, wordPosition.start.character), + wordPosition.end, + ); + const word = document.getText(wordPosition); + const match = word.match(USER_EXPRESSION); + if (match) { + const username = match[1]; + // JS and TS doc checks + if (((document.languageId === 'javascript') || (document.languageId === 'typescript')) + && JSDOC_NON_USERS.indexOf(username) >= 0) { + return; + } + // PHP doc checks + if ((document.languageId === 'php') && PHPDOC_NON_USERS.indexOf(username) >= 0) { + return; + } + return this.createHover(document.uri, username, wordPosition); + } + } else { + return; + } + } + + private async createHover( + uri: vscode.Uri, + username: string, + range: vscode.Range, + ): Promise { + try { + const folderManager = this.manager.getManagerForFile(uri); + if (!folderManager) { + return; + } + const origin = await folderManager.getPullRequestDefaults(); + const user = await folderManager.resolveUser(origin.owner, origin.repo, username); + if (user && user.name) { + /* __GDPR__ + "issue.userHover" : {} + */ + this.telemetry.sendTelemetryEvent('issues.userHover'); + return new vscode.Hover(userMarkdown(origin, user), range); + } else { + return; + } + } catch (e) { + // No need to notify about a hover that doesn't work + return; + } + } +} diff --git a/src/issues/util.ts b/src/issues/util.ts index 34aeda4896..1a3ab0ff60 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { URL } from 'url'; import LRUCache from 'lru-cache'; import * as marked from 'marked'; diff --git a/src/test/browser/index.ts b/src/test/browser/index.ts index 4a34fbef75..91e46f46ce 100644 --- a/src/test/browser/index.ts +++ b/src/test/browser/index.ts @@ -4,6 +4,7 @@ require('mocha/mocha'); import { mockWebviewEnvironment } from '../mocks/mockWebviewEnvironment'; import { EXTENSION_ID } from '../../constants'; + async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise { // Ensure the dev-mode extension is activated await vscode.extensions.getExtension(EXTENSION_ID)!.activate(); diff --git a/src/test/browser/runTests.ts b/src/test/browser/runTests.ts index fc9e45ff83..90c9398e9e 100644 --- a/src/test/browser/runTests.ts +++ b/src/test/browser/runTests.ts @@ -1,34 +1,35 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import { BrowserType, runTests } from '@vscode/test-web'; - -async function go() { - try { - const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); - const extensionTestsPath = path.resolve(__dirname, './index'); - console.log(extensionDevelopmentPath, extensionTestsPath); - const attachArgName = '--waitForDebugger='; - const waitForDebugger = process.argv.find(arg => arg.startsWith(attachArgName)); - const browserTypeName = '--browserType='; - const browserType = process.argv.find(arg => arg.startsWith(browserTypeName)); - - /** - * Basic usage - */ - await runTests({ - browserType: browserType ? browserType.slice(browserTypeName.length) : 'chromium', - extensionDevelopmentPath, - extensionTestsPath, - waitForDebugger: waitForDebugger ? Number(waitForDebugger.slice(attachArgName.length)) : undefined, - quality: 'stable' - }); - } catch (e) { - console.log(e); - } -} - -go(); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import { BrowserType, runTests } from '@vscode/test-web'; + +async function go() { + try { + const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); + const extensionTestsPath = path.resolve(__dirname, './index'); + console.log(extensionDevelopmentPath, extensionTestsPath); + const attachArgName = '--waitForDebugger='; + const waitForDebugger = process.argv.find(arg => arg.startsWith(attachArgName)); + const browserTypeName = '--browserType='; + const browserType = process.argv.find(arg => arg.startsWith(browserTypeName)); + + /** + * Basic usage + */ + await runTests({ + browserType: browserType ? browserType.slice(browserTypeName.length) : 'chromium', + extensionDevelopmentPath, + extensionTestsPath, + waitForDebugger: waitForDebugger ? Number(waitForDebugger.slice(attachArgName.length)) : undefined, + quality: 'stable' + }); + } catch (e) { + console.log(e); + } +} + +go(); diff --git a/src/test/builders.test.ts b/src/test/builders.test.ts index a8129f59ea..8b44832f6d 100644 --- a/src/test/builders.test.ts +++ b/src/test/builders.test.ts @@ -1,103 +1,104 @@ -import { createBuilderClass, createLink } from './builders/base'; -import { default as assert } from 'assert'; - -interface IGrandChild { - attr: number; -} - -const GrandChildBuilder = createBuilderClass()({ - attr: { default: 10 }, -}); - -interface IChild { - name: string; - grandchild: IGrandChild; -} - -const ChildBuilder = createBuilderClass()({ - name: { default: '' }, - grandchild: { linked: GrandChildBuilder }, -}); - -const b = new ChildBuilder(); -b.grandchild(gc => gc.attr(20)); - -interface IParent { - aStringProp: string; - aNumberProp: number; - aBooleanProp: boolean; - aChildProp: IChild; -} - -const ParentBuilder = createBuilderClass()({ - aStringProp: { default: 'abc' }, - aNumberProp: { default: 123 }, - aBooleanProp: { default: true }, - aChildProp: { linked: ChildBuilder }, -}); - -describe('Builders', function () { - it('creates setter methods for each field', function () { - const parent = new ParentBuilder() - .aStringProp('def') - .aNumberProp(456) - .aBooleanProp(false) - .aChildProp(child => { - child.name('non-default'); - child.grandchild(gc => { - gc.attr(5); - }); - }) - .build(); - - assert.strictEqual(parent.aStringProp, 'def'); - assert.strictEqual(parent.aNumberProp, 456); - assert.strictEqual(parent.aBooleanProp, false); - assert.strictEqual(parent.aChildProp.name, 'non-default'); - assert.strictEqual(parent.aChildProp.grandchild.attr, 5); - }); - - it('uses default values for unspecified fields', function () { - const parent = new ParentBuilder().aNumberProp(1000).build(); - - assert.strictEqual(parent.aStringProp, 'abc'); - assert.strictEqual(parent.aNumberProp, 1000); - assert.strictEqual(parent.aBooleanProp, true); - assert.strictEqual(parent.aChildProp.name, ''); - assert.strictEqual(parent.aChildProp.grandchild.attr, 10); - }); - - it('generates inline child builders with createLink()', function () { - interface IInline { - stringProp: string; - child: { - numberProp: number; - grandchild: { - boolProp: boolean; - }; - }; - } - - const InlineBuilder = createBuilderClass()({ - stringProp: { default: 'abc' }, - child: createLink()({ - numberProp: { default: 123 }, - grandchild: createLink()({ - boolProp: { default: true }, - }), - }), - }); - - const inline = new InlineBuilder() - .stringProp('def') - .child(c => { - c.numberProp(123); - c.grandchild(g => g.boolProp(false)); - }) - .build(); - - assert.strictEqual(inline.stringProp, 'def'); - assert.strictEqual(inline.child.numberProp, 123); - assert.strictEqual(inline.child.grandchild.boolProp, false); - }); -}); +import { createBuilderClass, createLink } from './builders/base'; +import { default as assert } from 'assert'; + +interface IGrandChild { + attr: number; + +} + +const GrandChildBuilder = createBuilderClass()({ + attr: { default: 10 }, +}); + +interface IChild { + name: string; + grandchild: IGrandChild; +} + +const ChildBuilder = createBuilderClass()({ + name: { default: '' }, + grandchild: { linked: GrandChildBuilder }, +}); + +const b = new ChildBuilder(); +b.grandchild(gc => gc.attr(20)); + +interface IParent { + aStringProp: string; + aNumberProp: number; + aBooleanProp: boolean; + aChildProp: IChild; +} + +const ParentBuilder = createBuilderClass()({ + aStringProp: { default: 'abc' }, + aNumberProp: { default: 123 }, + aBooleanProp: { default: true }, + aChildProp: { linked: ChildBuilder }, +}); + +describe('Builders', function () { + it('creates setter methods for each field', function () { + const parent = new ParentBuilder() + .aStringProp('def') + .aNumberProp(456) + .aBooleanProp(false) + .aChildProp(child => { + child.name('non-default'); + child.grandchild(gc => { + gc.attr(5); + }); + }) + .build(); + + assert.strictEqual(parent.aStringProp, 'def'); + assert.strictEqual(parent.aNumberProp, 456); + assert.strictEqual(parent.aBooleanProp, false); + assert.strictEqual(parent.aChildProp.name, 'non-default'); + assert.strictEqual(parent.aChildProp.grandchild.attr, 5); + }); + + it('uses default values for unspecified fields', function () { + const parent = new ParentBuilder().aNumberProp(1000).build(); + + assert.strictEqual(parent.aStringProp, 'abc'); + assert.strictEqual(parent.aNumberProp, 1000); + assert.strictEqual(parent.aBooleanProp, true); + assert.strictEqual(parent.aChildProp.name, ''); + assert.strictEqual(parent.aChildProp.grandchild.attr, 10); + }); + + it('generates inline child builders with createLink()', function () { + interface IInline { + stringProp: string; + child: { + numberProp: number; + grandchild: { + boolProp: boolean; + }; + }; + } + + const InlineBuilder = createBuilderClass()({ + stringProp: { default: 'abc' }, + child: createLink()({ + numberProp: { default: 123 }, + grandchild: createLink()({ + boolProp: { default: true }, + }), + }), + }); + + const inline = new InlineBuilder() + .stringProp('def') + .child(c => { + c.numberProp(123); + c.grandchild(g => g.boolProp(false)); + }) + .build(); + + assert.strictEqual(inline.stringProp, 'def'); + assert.strictEqual(inline.child.numberProp, 123); + assert.strictEqual(inline.child.grandchild.boolProp, false); + }); +}); diff --git a/src/test/builders/base.ts b/src/test/builders/base.ts index 95a17003cf..9612205024 100644 --- a/src/test/builders/base.ts +++ b/src/test/builders/base.ts @@ -1,292 +1,293 @@ -/** - * Metaprogramming-fu to implement a type-checked, recursive version of the builder pattern. See the README file in this directory for - * documentation about usage. The comments in here are intended to provide guidance in understanding the implementation. - * - * This file is divided into two major sections. The first is devoted to defining types that are sufficiently expressive to capture the - * shape of the dynamically generated {@link Builder} classes at compile time, and to lean on type inference to allow builder behavior - * to be specified concisely and consistently. The second section is used to construct the {@link BuilderClass} prototypes at run-time - * to match those types. - * - * Type parameter glossary: - * - * * `R`: Record. Type of the top-level object being constructed by the builder. - * * `T`: Template. Type of an object whose structure reflects that of its corresponding Record. That is, for each property within a Record, - * its Template will have a FieldTemplate describing the behavior of that field. - * * `F`: Field. Type of a single property within a Record. Note that the type of a field in one record is the record type of a sub-builder. - * * `FT`: Field template. Type of a single property within a Template. - * * `N`: Field name, usually acquired from a `keyof` expression. `F = R[N]` and `FT = T[N]` for some `N`. - */ - -/** - * {@link FieldTemplate} that describes a _scalar_ value; one that is initialized directly by a setter rather than constructed by a sub-builder. - * The default value provided is used by the builder if this field's value is not explicitly set. The default value's type must be - * assignable to the corresponding field in the builder's record type. - * - * @example - * interface Example { - * prop0: number; - * prop1: string[]; - * } - * - * const ExampleBuilder = createBuilderClass()({ - * prop0: {default: 123}, - * prop1: {default: []}, - * }); - */ -export interface ScalarFieldTemplate { - default: F; -} - -/** - * {@link FieldTemplate} that describes a _linked_ value; one that is constructed by a sub-builder. The builder class' record type must be - * assignable to the corresponding field in this builder's record type. The default value of this linked field is the one described by - * the default fields in its sub-builder's template. - * - * @example - * interface Child { - * attr: number; - * } - * - * const ChildBuilder = createBuilderClass()({ - * attr: {default: 0}, - * }); - * - * interface Example { - * child: Child; - * } - * - * const ExampleBuilder = createBuilderClass()({ - * child: {linked: ChildBuilder}, - * }); - */ -export interface LinkedFieldTemplate> { - linked: BuilderClass; -} - -/** - * Type union covering all available field template varieties. - */ -type FieldTemplate> = ScalarFieldTemplate | LinkedFieldTemplate; - -/** - * User-defined type guard to statically distinguish between {@link FieldTemplate} varieties. - * - * @param fieldTemplate Instance of a field template from some template object. - */ -function isLinked>( - fieldTemplate: FieldTemplate, -): fieldTemplate is LinkedFieldTemplate { - return (fieldTemplate as LinkedFieldTemplate).linked !== undefined; -} - -/** - * Description of the way the generated builder should treat each property of a Record type `R`. For each property in the original - * record type, its template contains a {@link FieldTemplate} of a matching type signature that indicates if this property - * is a _scalar_ (and if so, its default value,) or a _linked_ field (and if so, the Builder class that should be used to construct - * its value). - * - * Note that actual, useful Templates types are _subtypes_ of this one with either {@link ScalarFieldTemplate} or {@link LinkedFieldTemplate} - * properties. That's important because otherwise, the TypeScript compiler can't identify which kind of {@link FieldTemplate} a specific - * property is at compile time. Most type parameters that expect to operate on Templates are declared as `T extends Template` to - * preserve this information. - */ -export type Template = { - [P in keyof R]: FieldTemplate>; -}; - -/** - * {@link SetterFn|Setter function} used by a {@link Builder} to construct the value of a linked field with another kind of - * {@link Builder}. Call these setter functions with a block that accepts the sub-builder as its first argument. - * - * @example - * const parent = new ParentBuilder() - * .child((b) => { - * b.childProperty(123); - * }) - * .build(); - */ -type LinkedSetterFn, Self> = (block: (builder: Builder) => any) => Self; - -/** - * {@link SetterFn|Setter function} used by a {@link Builder} to populate a scalar field directly. - * - * @example - * const example = new ExampleBuilder() - * .someProperty('abc') - * .build(); - */ -type ScalarSetterFn = (value: F) => Self; - -/** - * Conditional type used to infer the call signature of a single setter function on a generated {@link Builder} type based on - * the (compile-time) type of a {@link Template} property. - */ -type SetterFn = FT extends LinkedFieldTemplate - ? LinkedSetterFn - : ScalarSetterFn; - -/** - * Instance that progressively assembles an object of record type `R` as you call a sequence of {@link SetterFn|setter functions}. - * When {@link #build} is called, any properties on the record that have not been explicitly initialized will be initialized to - * their default values, as specified by the Builder's template type, and the completed record will be returned. - * - * Setter functions are implemented as a {@link https://en.wikipedia.org/wiki/Fluent_interface|fluent interface}. - */ -export type Builder> = { - /** - * Setter function used to explicitly populate a single property of the constructed record. - */ - [P in keyof R]: SetterFn>; -} & { - build: () => R; -}; - -/** - * Class that constructs {@link Builder} instances for a specific record type `R`, according to a specific template `T`. - */ -type BuilderClass> = { - new (): Builder; -}; - -/** - * Abstract superclass containing behavior common to all {@link Builder|Builders} generated using {@link #createBuilderClass}. - */ -abstract class BaseBuilder { - private _underConstruction: Partial; - - constructor(private _template: Template) { - this._underConstruction = {}; - } - - /** - * Complete the record under construction by populating any missing fields with the default values specified by this builder's - * {@link Template}, then return it. - */ - build(): R { - // Populate any missing fields. - for (const fieldName in this._template) { - if (!(fieldName in this._underConstruction)) { - const fieldTemplate: FieldTemplate = this._template[fieldName]; - if (isLinked(fieldTemplate)) { - const builder = new fieldTemplate.linked(); - this._underConstruction[fieldName] = builder.build(); - } else { - this._underConstruction[fieldName] = fieldTemplate.default; - } - } - } - - // This is the cast that binds the *compile-time* work up above to the *run-time* work done by `createBuilderClass` below. - // TypeScript can't infer that it's safe; we need a cast to assert that, yes, `this_underConstruction` must be a complete - // R record now. - // - // We can be certain that this cast is safe at runtime because: - // * `this._template` is assignable to the type Template. - // * Template contains (at least!) a FieldTemplate for each property within R. - // * Each FieldTemplate is type-checked for consistency against its corresponding property in R. Scalar fields must have a - // default value that's assignable to the property type; linked fields must name a Builder class whose `build()` method - // returns a type that's assignable to the property type. - // * The "for" loop above ensures that a property on `this._underConstruction` is populated for each property in - // `this._template`. - // Thus, `this._underConstruction` must be assignable to type R after the loop completes. - return this._underConstruction as R; - } -} - -/** - * Create a {@link BuilderClass} that may be used to create {@link Builder|Builders} that progressively construct instances of - * a record type `R` with a fluent interface. - * - * This function returns another function that should be called with a {@link Template|template object} that contains a - * {@link FieldTemplate} for each property in the record type `R`. Each field template dictates the style of setter function - * generated to populate that field's value on records under construction: {@link ScalarFieldTemplate|scalar fields}, specified - * with `{ default: 'value' }`, create direct setter functions that accept the field's value directly; - * {@link LinkedFieldTemplate|linked fields}, specified with `{ linked: SubBuilderClass }`, create functions that accept a - * function to be called with an instance of the named sub-builder class. - * - * The function-returning-a-function style is used so that the record type parameter `R` may be specified explicitly, but the template - * type parameter `T` may be inferred from its argument. If TypeScript supports - * {@link https://github.com/Microsoft/TypeScript/issues/26242|partial type argument inference} we can simplify this to be a single - * function call. - * - * @example - * const ExampleBuilder = createBuilderClass()({ - * someProperty: {default: 'the-default'}, - * anotherProperty: {default: true}, - * child: {linked: ChildBuilder}, - * secondChild: {linked: ChildBuilder}, - * }); - * - * const example = new ExampleBuilder() - * .someProperty('different') - * .build(); - */ -export function createBuilderClass() { - return >(template: T): BuilderClass => { - // This cast is safe because the template loop below is guaranteed to populate setter and sub-builder methods - // for each keyof R. - const DynamicBuilder: BuilderClass = class extends BaseBuilder { - constructor() { - super(template); - } - } as any; - - // Dynamically construct a scalar setter function for a named field. This setter method's signature must match that - // of ScalarSetterFn. - function defineScalarSetter(fieldName: N) { - DynamicBuilder.prototype[fieldName] = function (value: F) { - this._underConstruction[fieldName] = value; - return this; - }; - } - - // Dynamically construct a linked setter function for a named field. This setter method's signature must match that - // of LinkedSetterFn. - function defineLinkedSetter(fieldName: N, builderClass: BuilderClass>) { - DynamicBuilder.prototype[fieldName] = function (block: (builder: Builder>) => void) { - const builder = new builderClass(); - block(builder); - this._underConstruction[fieldName] = builder.build(); - return this; - }; - } - - for (const fieldName in template) { - const fieldTemplate = template[fieldName]; - if (isLinked(fieldTemplate)) { - defineLinkedSetter(fieldName, fieldTemplate.linked); - } else { - defineScalarSetter(fieldName); - } - } - - return DynamicBuilder; - }; -} - -/** - * Concisely create a sub-builder class directly within the template provided to a parent builder. This is useful when dealing with - * types that nest deeply and are only used once or are anonymous (for example: GraphQL responses). - * - * The function-returning-a-function style is used so that the record type parameter `R` may be specified explicitly, but the template - * type parameter `T` may be inferred from its argument. - * - * @example - * - * interface Compound { - * attr: number; - * child: { - * subAttr: boolean; - * }; - * } - * - * const CompoundBuilder = createBuilderClass()({ - * attr: {default: 0}, - * child: createLink()({ - * subAttr: {default: true}, - * }), - * }); - */ -export function createLink(): >(template: T) => LinkedFieldTemplate { - return >(template: T) => ({ linked: createBuilderClass()(template) }); -} +/** + * Metaprogramming-fu to implement a type-checked, recursive version of the builder pattern. See the README file in this directory for + * documentation about usage. The comments in here are intended to provide guidance in understanding the implementation. + * + * This file is divided into two major sections. The first is devoted to defining types that are sufficiently expressive to capture the + + * shape of the dynamically generated {@link Builder} classes at compile time, and to lean on type inference to allow builder behavior + * to be specified concisely and consistently. The second section is used to construct the {@link BuilderClass} prototypes at run-time + * to match those types. + * + * Type parameter glossary: + * + * * `R`: Record. Type of the top-level object being constructed by the builder. + * * `T`: Template. Type of an object whose structure reflects that of its corresponding Record. That is, for each property within a Record, + * its Template will have a FieldTemplate describing the behavior of that field. + * * `F`: Field. Type of a single property within a Record. Note that the type of a field in one record is the record type of a sub-builder. + * * `FT`: Field template. Type of a single property within a Template. + * * `N`: Field name, usually acquired from a `keyof` expression. `F = R[N]` and `FT = T[N]` for some `N`. + */ + +/** + * {@link FieldTemplate} that describes a _scalar_ value; one that is initialized directly by a setter rather than constructed by a sub-builder. + * The default value provided is used by the builder if this field's value is not explicitly set. The default value's type must be + * assignable to the corresponding field in the builder's record type. + * + * @example + * interface Example { + * prop0: number; + * prop1: string[]; + * } + * + * const ExampleBuilder = createBuilderClass()({ + * prop0: {default: 123}, + * prop1: {default: []}, + * }); + */ +export interface ScalarFieldTemplate { + default: F; +} + +/** + * {@link FieldTemplate} that describes a _linked_ value; one that is constructed by a sub-builder. The builder class' record type must be + * assignable to the corresponding field in this builder's record type. The default value of this linked field is the one described by + * the default fields in its sub-builder's template. + * + * @example + * interface Child { + * attr: number; + * } + * + * const ChildBuilder = createBuilderClass()({ + * attr: {default: 0}, + * }); + * + * interface Example { + * child: Child; + * } + * + * const ExampleBuilder = createBuilderClass()({ + * child: {linked: ChildBuilder}, + * }); + */ +export interface LinkedFieldTemplate> { + linked: BuilderClass; +} + +/** + * Type union covering all available field template varieties. + */ +type FieldTemplate> = ScalarFieldTemplate | LinkedFieldTemplate; + +/** + * User-defined type guard to statically distinguish between {@link FieldTemplate} varieties. + * + * @param fieldTemplate Instance of a field template from some template object. + */ +function isLinked>( + fieldTemplate: FieldTemplate, +): fieldTemplate is LinkedFieldTemplate { + return (fieldTemplate as LinkedFieldTemplate).linked !== undefined; +} + +/** + * Description of the way the generated builder should treat each property of a Record type `R`. For each property in the original + * record type, its template contains a {@link FieldTemplate} of a matching type signature that indicates if this property + * is a _scalar_ (and if so, its default value,) or a _linked_ field (and if so, the Builder class that should be used to construct + * its value). + * + * Note that actual, useful Templates types are _subtypes_ of this one with either {@link ScalarFieldTemplate} or {@link LinkedFieldTemplate} + * properties. That's important because otherwise, the TypeScript compiler can't identify which kind of {@link FieldTemplate} a specific + * property is at compile time. Most type parameters that expect to operate on Templates are declared as `T extends Template` to + * preserve this information. + */ +export type Template = { + [P in keyof R]: FieldTemplate>; +}; + +/** + * {@link SetterFn|Setter function} used by a {@link Builder} to construct the value of a linked field with another kind of + * {@link Builder}. Call these setter functions with a block that accepts the sub-builder as its first argument. + * + * @example + * const parent = new ParentBuilder() + * .child((b) => { + * b.childProperty(123); + * }) + * .build(); + */ +type LinkedSetterFn, Self> = (block: (builder: Builder) => any) => Self; + +/** + * {@link SetterFn|Setter function} used by a {@link Builder} to populate a scalar field directly. + * + * @example + * const example = new ExampleBuilder() + * .someProperty('abc') + * .build(); + */ +type ScalarSetterFn = (value: F) => Self; + +/** + * Conditional type used to infer the call signature of a single setter function on a generated {@link Builder} type based on + * the (compile-time) type of a {@link Template} property. + */ +type SetterFn = FT extends LinkedFieldTemplate + ? LinkedSetterFn + : ScalarSetterFn; + +/** + * Instance that progressively assembles an object of record type `R` as you call a sequence of {@link SetterFn|setter functions}. + * When {@link #build} is called, any properties on the record that have not been explicitly initialized will be initialized to + * their default values, as specified by the Builder's template type, and the completed record will be returned. + * + * Setter functions are implemented as a {@link https://en.wikipedia.org/wiki/Fluent_interface|fluent interface}. + */ +export type Builder> = { + /** + * Setter function used to explicitly populate a single property of the constructed record. + */ + [P in keyof R]: SetterFn>; +} & { + build: () => R; +}; + +/** + * Class that constructs {@link Builder} instances for a specific record type `R`, according to a specific template `T`. + */ +type BuilderClass> = { + new (): Builder; +}; + +/** + * Abstract superclass containing behavior common to all {@link Builder|Builders} generated using {@link #createBuilderClass}. + */ +abstract class BaseBuilder { + private _underConstruction: Partial; + + constructor(private _template: Template) { + this._underConstruction = {}; + } + + /** + * Complete the record under construction by populating any missing fields with the default values specified by this builder's + * {@link Template}, then return it. + */ + build(): R { + // Populate any missing fields. + for (const fieldName in this._template) { + if (!(fieldName in this._underConstruction)) { + const fieldTemplate: FieldTemplate = this._template[fieldName]; + if (isLinked(fieldTemplate)) { + const builder = new fieldTemplate.linked(); + this._underConstruction[fieldName] = builder.build(); + } else { + this._underConstruction[fieldName] = fieldTemplate.default; + } + } + } + + // This is the cast that binds the *compile-time* work up above to the *run-time* work done by `createBuilderClass` below. + // TypeScript can't infer that it's safe; we need a cast to assert that, yes, `this_underConstruction` must be a complete + // R record now. + // + // We can be certain that this cast is safe at runtime because: + // * `this._template` is assignable to the type Template. + // * Template contains (at least!) a FieldTemplate for each property within R. + // * Each FieldTemplate is type-checked for consistency against its corresponding property in R. Scalar fields must have a + // default value that's assignable to the property type; linked fields must name a Builder class whose `build()` method + // returns a type that's assignable to the property type. + // * The "for" loop above ensures that a property on `this._underConstruction` is populated for each property in + // `this._template`. + // Thus, `this._underConstruction` must be assignable to type R after the loop completes. + return this._underConstruction as R; + } +} + +/** + * Create a {@link BuilderClass} that may be used to create {@link Builder|Builders} that progressively construct instances of + * a record type `R` with a fluent interface. + * + * This function returns another function that should be called with a {@link Template|template object} that contains a + * {@link FieldTemplate} for each property in the record type `R`. Each field template dictates the style of setter function + * generated to populate that field's value on records under construction: {@link ScalarFieldTemplate|scalar fields}, specified + * with `{ default: 'value' }`, create direct setter functions that accept the field's value directly; + * {@link LinkedFieldTemplate|linked fields}, specified with `{ linked: SubBuilderClass }`, create functions that accept a + * function to be called with an instance of the named sub-builder class. + * + * The function-returning-a-function style is used so that the record type parameter `R` may be specified explicitly, but the template + * type parameter `T` may be inferred from its argument. If TypeScript supports + * {@link https://github.com/Microsoft/TypeScript/issues/26242|partial type argument inference} we can simplify this to be a single + * function call. + * + * @example + * const ExampleBuilder = createBuilderClass()({ + * someProperty: {default: 'the-default'}, + * anotherProperty: {default: true}, + * child: {linked: ChildBuilder}, + * secondChild: {linked: ChildBuilder}, + * }); + * + * const example = new ExampleBuilder() + * .someProperty('different') + * .build(); + */ +export function createBuilderClass() { + return >(template: T): BuilderClass => { + // This cast is safe because the template loop below is guaranteed to populate setter and sub-builder methods + // for each keyof R. + const DynamicBuilder: BuilderClass = class extends BaseBuilder { + constructor() { + super(template); + } + } as any; + + // Dynamically construct a scalar setter function for a named field. This setter method's signature must match that + // of ScalarSetterFn. + function defineScalarSetter(fieldName: N) { + DynamicBuilder.prototype[fieldName] = function (value: F) { + this._underConstruction[fieldName] = value; + return this; + }; + } + + // Dynamically construct a linked setter function for a named field. This setter method's signature must match that + // of LinkedSetterFn. + function defineLinkedSetter(fieldName: N, builderClass: BuilderClass>) { + DynamicBuilder.prototype[fieldName] = function (block: (builder: Builder>) => void) { + const builder = new builderClass(); + block(builder); + this._underConstruction[fieldName] = builder.build(); + return this; + }; + } + + for (const fieldName in template) { + const fieldTemplate = template[fieldName]; + if (isLinked(fieldTemplate)) { + defineLinkedSetter(fieldName, fieldTemplate.linked); + } else { + defineScalarSetter(fieldName); + } + } + + return DynamicBuilder; + }; +} + +/** + * Concisely create a sub-builder class directly within the template provided to a parent builder. This is useful when dealing with + * types that nest deeply and are only used once or are anonymous (for example: GraphQL responses). + * + * The function-returning-a-function style is used so that the record type parameter `R` may be specified explicitly, but the template + * type parameter `T` may be inferred from its argument. + * + * @example + * + * interface Compound { + * attr: number; + * child: { + * subAttr: boolean; + * }; + * } + * + * const CompoundBuilder = createBuilderClass()({ + * attr: {default: 0}, + * child: createLink()({ + * subAttr: {default: true}, + * }), + * }); + */ +export function createLink(): >(template: T) => LinkedFieldTemplate { + return >(template: T) => ({ linked: createBuilderClass()(template) }); +} diff --git a/src/test/builders/graphql/latestReviewCommitBuilder.ts b/src/test/builders/graphql/latestReviewCommitBuilder.ts index a51807ae01..7afc2bab80 100644 --- a/src/test/builders/graphql/latestReviewCommitBuilder.ts +++ b/src/test/builders/graphql/latestReviewCommitBuilder.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass, createLink } from '../base'; import { LatestReviewCommitResponse } from '../../../github/graphql'; diff --git a/src/test/builders/graphql/pullRequestBuilder.ts b/src/test/builders/graphql/pullRequestBuilder.ts index 1483f95fce..4a86e192ef 100644 --- a/src/test/builders/graphql/pullRequestBuilder.ts +++ b/src/test/builders/graphql/pullRequestBuilder.ts @@ -1,109 +1,110 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createBuilderClass, createLink } from '../base'; -import { BaseRefRepository, DefaultCommitMessage, DefaultCommitTitle, PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; - -import { RateLimitBuilder } from './rateLimitBuilder'; - -const RefRepositoryBuilder = createBuilderClass()({ - isInOrganization: { default: false }, - owner: createLink()({ - login: { default: 'me' }, - }), - url: { default: 'https://github.com/owner/repo' }, -}); - -const BaseRefRepositoryBuilder = createBuilderClass()({ - isInOrganization: { default: false }, - owner: createLink()({ - login: { default: 'me' }, - }), - url: { default: 'https://github.com/owner/repo' }, - mergeCommitMessage: { default: DefaultCommitMessage.commitMessages }, - mergeCommitTitle: { default: DefaultCommitTitle.mergeMessage }, - squashMergeCommitMessage: { default: DefaultCommitMessage.prBody }, - squashMergeCommitTitle: { default: DefaultCommitTitle.prTitle }, -}); - -const RefBuilder = createBuilderClass()({ - name: { default: 'main' }, - repository: { linked: RefRepositoryBuilder }, - target: createLink()({ - oid: { default: '0000000000000000000000000000000000000000' }, - }), -}); - -type Repository = PullRequestResponse['repository']; -type PullRequest = Repository['pullRequest']; -type Author = PullRequest['author']; -type AssigneesConn = PullRequest['assignees']; -type CommitsConn = PullRequest['commits']; -type LabelConn = PullRequest['labels']; - -export const PullRequestBuilder = createBuilderClass()({ - repository: createLink()({ - pullRequest: createLink()({ - id: { default: 'pr0' }, - databaseId: { default: 1234 }, - number: { default: 1347 }, - url: { default: 'https://github.com/owner/repo/pulls/1347' }, - state: { default: 'OPEN' }, - body: { default: '**markdown**' }, - bodyHTML: { default: '

markdown

' }, - title: { default: 'plz merge' }, - titleHTML: { default: 'plz merge' }, - assignees: createLink()({ - nodes: { - default: [ - { - avatarUrl: '', - email: '', - login: 'me', - url: 'https://github.com/me', - id: '123' - }, - ], - }, - }), - author: createLink()({ - login: { default: 'me' }, - url: { default: 'https://github.com/me' }, - avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, - id: { default: '123' }, - }), - createdAt: { default: '2019-01-01T10:00:00Z' }, - updatedAt: { default: '2019-01-01T11:00:00Z' }, - headRef: { linked: RefBuilder }, - headRefName: { default: 'pr-branch' }, - headRefOid: { default: '0000000000000000000000000000000000000000' }, - headRepository: { linked: RefRepositoryBuilder }, - baseRef: { linked: RefBuilder }, - baseRefName: { default: 'main' }, - baseRefOid: { default: '0000000000000000000000000000000000000000' }, - baseRepository: { linked: BaseRefRepositoryBuilder }, - labels: createLink()({ - nodes: { default: [] }, - }), - merged: { default: false }, - mergeable: { default: 'MERGEABLE' }, - mergeStateStatus: { default: 'CLEAN' }, - isDraft: { default: false }, - suggestedReviewers: { default: [] }, - viewerCanEnableAutoMerge: { default: false }, - viewerCanDisableAutoMerge: { default: false }, - commits: createLink()({ - nodes: { - default: [ - { commit: { message: 'commit 1' } }, - ] - } - }) - }) - }), - rateLimit: { linked: RateLimitBuilder }, -}); - -export type PullRequestBuilder = InstanceType; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { createBuilderClass, createLink } from '../base'; +import { BaseRefRepository, DefaultCommitMessage, DefaultCommitTitle, PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; + +import { RateLimitBuilder } from './rateLimitBuilder'; + +const RefRepositoryBuilder = createBuilderClass()({ + isInOrganization: { default: false }, + owner: createLink()({ + login: { default: 'me' }, + }), + url: { default: 'https://github.com/owner/repo' }, +}); + +const BaseRefRepositoryBuilder = createBuilderClass()({ + isInOrganization: { default: false }, + owner: createLink()({ + login: { default: 'me' }, + }), + url: { default: 'https://github.com/owner/repo' }, + mergeCommitMessage: { default: DefaultCommitMessage.commitMessages }, + mergeCommitTitle: { default: DefaultCommitTitle.mergeMessage }, + squashMergeCommitMessage: { default: DefaultCommitMessage.prBody }, + squashMergeCommitTitle: { default: DefaultCommitTitle.prTitle }, +}); + +const RefBuilder = createBuilderClass()({ + name: { default: 'main' }, + repository: { linked: RefRepositoryBuilder }, + target: createLink()({ + oid: { default: '0000000000000000000000000000000000000000' }, + }), +}); + +type Repository = PullRequestResponse['repository']; +type PullRequest = Repository['pullRequest']; +type Author = PullRequest['author']; +type AssigneesConn = PullRequest['assignees']; +type CommitsConn = PullRequest['commits']; +type LabelConn = PullRequest['labels']; + +export const PullRequestBuilder = createBuilderClass()({ + repository: createLink()({ + pullRequest: createLink()({ + id: { default: 'pr0' }, + databaseId: { default: 1234 }, + number: { default: 1347 }, + url: { default: 'https://github.com/owner/repo/pulls/1347' }, + state: { default: 'OPEN' }, + body: { default: '**markdown**' }, + bodyHTML: { default: '

markdown

' }, + title: { default: 'plz merge' }, + titleHTML: { default: 'plz merge' }, + assignees: createLink()({ + nodes: { + default: [ + { + avatarUrl: '', + email: '', + login: 'me', + url: 'https://github.com/me', + id: '123' + }, + ], + }, + }), + author: createLink()({ + login: { default: 'me' }, + url: { default: 'https://github.com/me' }, + avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, + id: { default: '123' }, + }), + createdAt: { default: '2019-01-01T10:00:00Z' }, + updatedAt: { default: '2019-01-01T11:00:00Z' }, + headRef: { linked: RefBuilder }, + headRefName: { default: 'pr-branch' }, + headRefOid: { default: '0000000000000000000000000000000000000000' }, + headRepository: { linked: RefRepositoryBuilder }, + baseRef: { linked: RefBuilder }, + baseRefName: { default: 'main' }, + baseRefOid: { default: '0000000000000000000000000000000000000000' }, + baseRepository: { linked: BaseRefRepositoryBuilder }, + labels: createLink()({ + nodes: { default: [] }, + }), + merged: { default: false }, + mergeable: { default: 'MERGEABLE' }, + mergeStateStatus: { default: 'CLEAN' }, + isDraft: { default: false }, + suggestedReviewers: { default: [] }, + viewerCanEnableAutoMerge: { default: false }, + viewerCanDisableAutoMerge: { default: false }, + commits: createLink()({ + nodes: { + default: [ + { commit: { message: 'commit 1' } }, + ] + } + }) + }) + }), + rateLimit: { linked: RateLimitBuilder }, +}); + +export type PullRequestBuilder = InstanceType; diff --git a/src/test/builders/graphql/rateLimitBuilder.ts b/src/test/builders/graphql/rateLimitBuilder.ts index 66cbe6d365..5dc4be15e2 100644 --- a/src/test/builders/graphql/rateLimitBuilder.ts +++ b/src/test/builders/graphql/rateLimitBuilder.ts @@ -1,11 +1,12 @@ -import { RateLimit } from '../../../github/graphql'; -import { createBuilderClass } from '../base'; - -export const RateLimitBuilder = createBuilderClass()({ - limit: { default: 5000 }, - cost: { default: 0 }, - remaining: { default: 4999 }, - resetAt: { default: '3019-01-01T00:00:00Z' }, -}); - -export type RateLimitBuilder = InstanceType; +import { RateLimit } from '../../../github/graphql'; +import { createBuilderClass } from '../base'; + +export const RateLimitBuilder = createBuilderClass()({ + limit: { default: 5000 }, + + cost: { default: 0 }, + remaining: { default: 4999 }, + resetAt: { default: '3019-01-01T00:00:00Z' }, +}); + +export type RateLimitBuilder = InstanceType; diff --git a/src/test/builders/graphql/timelineEventsBuilder.ts b/src/test/builders/graphql/timelineEventsBuilder.ts index 48eeb16e79..a733f25b0f 100644 --- a/src/test/builders/graphql/timelineEventsBuilder.ts +++ b/src/test/builders/graphql/timelineEventsBuilder.ts @@ -1,21 +1,22 @@ -import { createBuilderClass, createLink } from '../base'; -import { TimelineEventsResponse } from '../../../github/graphql'; - -import { RateLimitBuilder } from './rateLimitBuilder'; - -type Repository = TimelineEventsResponse['repository']; -type PullRequest = Repository['pullRequest']; -type TimelineConn = PullRequest['timelineItems']; - -export const TimelineEventsBuilder = createBuilderClass()({ - repository: createLink()({ - pullRequest: createLink()({ - timelineItems: createLink()({ - nodes: { default: [] }, - }), - }), - }), - rateLimit: { linked: RateLimitBuilder }, -}); - -export type TimelineEventsBuilder = InstanceType; +import { createBuilderClass, createLink } from '../base'; +import { TimelineEventsResponse } from '../../../github/graphql'; + +import { RateLimitBuilder } from './rateLimitBuilder'; + + +type Repository = TimelineEventsResponse['repository']; +type PullRequest = Repository['pullRequest']; +type TimelineConn = PullRequest['timelineItems']; + +export const TimelineEventsBuilder = createBuilderClass()({ + repository: createLink()({ + pullRequest: createLink()({ + timelineItems: createLink()({ + nodes: { default: [] }, + }), + }), + }), + rateLimit: { linked: RateLimitBuilder }, +}); + +export type TimelineEventsBuilder = InstanceType; diff --git a/src/test/builders/managedPullRequestBuilder.ts b/src/test/builders/managedPullRequestBuilder.ts index a1ca3865cc..bd517801a1 100644 --- a/src/test/builders/managedPullRequestBuilder.ts +++ b/src/test/builders/managedPullRequestBuilder.ts @@ -1,60 +1,61 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - PullRequestResponse as PullRequestGraphQL, - TimelineEventsResponse as TimelineEventsGraphQL, - LatestReviewCommitResponse as LatestReviewCommitGraphQL -} from '../../github/graphql'; -import { PullRequestBuilder as PullRequestGraphQLBuilder } from './graphql/pullRequestBuilder'; -import { - PullRequestBuilder as PullRequestRESTBuilder, - PullRequestUnion as PullRequestREST, -} from './rest/pullRequestBuilder'; -import { TimelineEventsBuilder as TimelineEventsGraphQLBuilder } from './graphql/timelineEventsBuilder'; -import { LatestReviewCommitBuilder as LatestReviewCommitGraphQLBuilder } from './graphql/latestReviewCommitBuilder'; -import { RepoUnion as RepositoryREST, RepositoryBuilder as RepositoryRESTBuilder } from './rest/repoBuilder'; -import { CombinedStatusBuilder as CombinedStatusRESTBuilder } from './rest/combinedStatusBuilder'; -import { ReviewRequestsBuilder as ReviewRequestsRESTBuilder } from './rest/reviewRequestsBuilder'; -import { createBuilderClass } from './base'; -import { PullRequestChecks } from '../../github/interface'; -import { OctokitCommon } from '../../github/common'; - -type ResponseFlavor = APIFlavor extends 'graphql' ? GQL : RST; - -export interface ManagedPullRequest { - pullRequest: ResponseFlavor; - timelineEvents: ResponseFlavor< - APIFlavor, - TimelineEventsGraphQL, - OctokitCommon.IssuesListEventsForTimelineResponseData[] - >; - latestReviewCommit: ResponseFlavor; - repositoryREST: RepositoryREST; - combinedStatusREST: PullRequestChecks; - reviewRequestsREST: OctokitCommon.PullsListRequestedReviewersResponseData; -} - -export const ManagedGraphQLPullRequestBuilder = createBuilderClass>()({ - pullRequest: { linked: PullRequestGraphQLBuilder }, - timelineEvents: { linked: TimelineEventsGraphQLBuilder }, - latestReviewCommit: { linked: LatestReviewCommitGraphQLBuilder }, - repositoryREST: { linked: RepositoryRESTBuilder }, - combinedStatusREST: { linked: CombinedStatusRESTBuilder }, - reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, -}); - -export type ManagedGraphQLPullRequestBuilder = InstanceType; - -export const ManagedRESTPullRequestBuilder = createBuilderClass>()({ - pullRequest: { linked: PullRequestRESTBuilder }, - timelineEvents: { default: [] }, - latestReviewCommit: { default: 'abc' }, - repositoryREST: { linked: RepositoryRESTBuilder }, - combinedStatusREST: { linked: CombinedStatusRESTBuilder }, - reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, -}); - -export type ManagedRESTPullRequestBuilder = InstanceType; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { + PullRequestResponse as PullRequestGraphQL, + TimelineEventsResponse as TimelineEventsGraphQL, + LatestReviewCommitResponse as LatestReviewCommitGraphQL +} from '../../github/graphql'; +import { PullRequestBuilder as PullRequestGraphQLBuilder } from './graphql/pullRequestBuilder'; +import { + PullRequestBuilder as PullRequestRESTBuilder, + PullRequestUnion as PullRequestREST, +} from './rest/pullRequestBuilder'; +import { TimelineEventsBuilder as TimelineEventsGraphQLBuilder } from './graphql/timelineEventsBuilder'; +import { LatestReviewCommitBuilder as LatestReviewCommitGraphQLBuilder } from './graphql/latestReviewCommitBuilder'; +import { RepoUnion as RepositoryREST, RepositoryBuilder as RepositoryRESTBuilder } from './rest/repoBuilder'; +import { CombinedStatusBuilder as CombinedStatusRESTBuilder } from './rest/combinedStatusBuilder'; +import { ReviewRequestsBuilder as ReviewRequestsRESTBuilder } from './rest/reviewRequestsBuilder'; +import { createBuilderClass } from './base'; +import { PullRequestChecks } from '../../github/interface'; +import { OctokitCommon } from '../../github/common'; + +type ResponseFlavor = APIFlavor extends 'graphql' ? GQL : RST; + +export interface ManagedPullRequest { + pullRequest: ResponseFlavor; + timelineEvents: ResponseFlavor< + APIFlavor, + TimelineEventsGraphQL, + OctokitCommon.IssuesListEventsForTimelineResponseData[] + >; + latestReviewCommit: ResponseFlavor; + repositoryREST: RepositoryREST; + combinedStatusREST: PullRequestChecks; + reviewRequestsREST: OctokitCommon.PullsListRequestedReviewersResponseData; +} + +export const ManagedGraphQLPullRequestBuilder = createBuilderClass>()({ + pullRequest: { linked: PullRequestGraphQLBuilder }, + timelineEvents: { linked: TimelineEventsGraphQLBuilder }, + latestReviewCommit: { linked: LatestReviewCommitGraphQLBuilder }, + repositoryREST: { linked: RepositoryRESTBuilder }, + combinedStatusREST: { linked: CombinedStatusRESTBuilder }, + reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, +}); + +export type ManagedGraphQLPullRequestBuilder = InstanceType; + +export const ManagedRESTPullRequestBuilder = createBuilderClass>()({ + pullRequest: { linked: PullRequestRESTBuilder }, + timelineEvents: { default: [] }, + latestReviewCommit: { default: 'abc' }, + repositoryREST: { linked: RepositoryRESTBuilder }, + combinedStatusREST: { linked: CombinedStatusRESTBuilder }, + reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, +}); + +export type ManagedRESTPullRequestBuilder = InstanceType; diff --git a/src/test/builders/rest/combinedStatusBuilder.ts b/src/test/builders/rest/combinedStatusBuilder.ts index dc87383acc..b009624b2e 100644 --- a/src/test/builders/rest/combinedStatusBuilder.ts +++ b/src/test/builders/rest/combinedStatusBuilder.ts @@ -1,32 +1,33 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; -import { CheckState, PullRequestChecks } from '../../../github/interface'; - -export const StatusItemBuilder = createBuilderClass()({ - url: { - default: 'https://api.github.com/repos/octocat/Hello-World/statuses/0000000000000000000000000000000000000000', - }, - avatar_url: { default: 'https://github.com/images/error/hubot_happy.gif' }, - id: { default: 1 }, - node_id: { default: 'MDY6U3RhdHVzMQ==' }, - state: { default: 'success' }, - description: { default: 'Build has completed successfully' }, - target_url: { default: 'https://ci.example.com/1000/output' }, - context: { default: 'continuous-integration/jenkins' }, - created_at: { default: '2012-07-20T01:19:13Z' }, - updated_at: { default: '2012-07-20T01:19:13Z' }, -}); - -export type StatusItemBuilder = InstanceType; - -export const CombinedStatusBuilder = createBuilderClass()({ - state: { default: CheckState.Success }, - statuses: { default: [] }, -}); - -export type CombinedStatusBuilder = InstanceType; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; +import { CheckState, PullRequestChecks } from '../../../github/interface'; + +export const StatusItemBuilder = createBuilderClass()({ + url: { + default: 'https://api.github.com/repos/octocat/Hello-World/statuses/0000000000000000000000000000000000000000', + }, + avatar_url: { default: 'https://github.com/images/error/hubot_happy.gif' }, + id: { default: 1 }, + node_id: { default: 'MDY6U3RhdHVzMQ==' }, + state: { default: 'success' }, + description: { default: 'Build has completed successfully' }, + target_url: { default: 'https://ci.example.com/1000/output' }, + context: { default: 'continuous-integration/jenkins' }, + created_at: { default: '2012-07-20T01:19:13Z' }, + updated_at: { default: '2012-07-20T01:19:13Z' }, +}); + +export type StatusItemBuilder = InstanceType; + +export const CombinedStatusBuilder = createBuilderClass()({ + state: { default: CheckState.Success }, + statuses: { default: [] }, +}); + +export type CombinedStatusBuilder = InstanceType; diff --git a/src/test/builders/rest/organizationBuilder.ts b/src/test/builders/rest/organizationBuilder.ts index 509ac90858..8596f252c8 100644 --- a/src/test/builders/rest/organizationBuilder.ts +++ b/src/test/builders/rest/organizationBuilder.ts @@ -1,25 +1,26 @@ -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -export const OrganizationBuilder = createBuilderClass()({ - login: { default: 'octocat' }, - id: { default: 1 }, - node_id: { default: 'MDQ6VXNlcjE=' }, - avatar_url: { default: 'https://github.com/images/error/octocat_happy.gif' }, - gravatar_id: { default: '' }, - url: { default: 'https://api.github.com/users/octocat' }, - html_url: { default: 'https://github.com/octocat' }, - followers_url: { default: 'https://api.github.com/users/octocat/followers' }, - following_url: { default: 'https://api.github.com/users/octocat/following{/other_user}' }, - gists_url: { default: 'https://api.github.com/users/octocat/gists{/gist_id}' }, - starred_url: { default: 'https://api.github.com/users/octocat/starred{/owner}{/repo}' }, - subscriptions_url: { default: 'https://api.github.com/users/octocat/subscriptions' }, - organizations_url: { default: 'https://api.github.com/users/octocat/orgs' }, - repos_url: { default: 'https://api.github.com/users/octocat/repos' }, - events_url: { default: 'https://api.github.com/users/octocat/events{/privacy}' }, - received_events_url: { default: 'https://api.github.com/users/octocat/received_events' }, - type: { default: 'Organization' }, - site_admin: { default: false }, -}); - -export type OrganizationBuilder = InstanceType; +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + +export const OrganizationBuilder = createBuilderClass()({ + login: { default: 'octocat' }, + + id: { default: 1 }, + node_id: { default: 'MDQ6VXNlcjE=' }, + avatar_url: { default: 'https://github.com/images/error/octocat_happy.gif' }, + gravatar_id: { default: '' }, + url: { default: 'https://api.github.com/users/octocat' }, + html_url: { default: 'https://github.com/octocat' }, + followers_url: { default: 'https://api.github.com/users/octocat/followers' }, + following_url: { default: 'https://api.github.com/users/octocat/following{/other_user}' }, + gists_url: { default: 'https://api.github.com/users/octocat/gists{/gist_id}' }, + starred_url: { default: 'https://api.github.com/users/octocat/starred{/owner}{/repo}' }, + subscriptions_url: { default: 'https://api.github.com/users/octocat/subscriptions' }, + organizations_url: { default: 'https://api.github.com/users/octocat/orgs' }, + repos_url: { default: 'https://api.github.com/users/octocat/repos' }, + events_url: { default: 'https://api.github.com/users/octocat/events{/privacy}' }, + received_events_url: { default: 'https://api.github.com/users/octocat/received_events' }, + type: { default: 'Organization' }, + site_admin: { default: false }, +}); + +export type OrganizationBuilder = InstanceType; diff --git a/src/test/builders/rest/pullRequestBuilder.ts b/src/test/builders/rest/pullRequestBuilder.ts index 70af54d5f8..53c30fd4e0 100644 --- a/src/test/builders/rest/pullRequestBuilder.ts +++ b/src/test/builders/rest/pullRequestBuilder.ts @@ -1,108 +1,109 @@ -import { UserBuilder } from './userBuilder'; -import { RefBuilder } from './refBuilder'; -import { createLink, createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -export type PullRequestUnion = OctokitCommon.PullsGetResponseData; -type Links = PullRequestUnion['_links']; -type Milestone = PullRequestUnion['milestone']; - -export const PullRequestBuilder = createBuilderClass()({ - id: { default: 0 }, - node_id: { default: 'node0' }, - number: { default: 1347 }, - url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' }, - state: { default: 'open' }, - html_url: { default: 'https://github.com/octocat/reponame/pull/1347' }, - diff_url: { default: 'https://github.com/octocat/reponame/pull/1347.diff' }, - patch_url: { default: 'https://github.com/octocat/reponame/pull/1347.patch' }, - issue_url: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' }, - commits_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' }, - review_comments_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, - review_comment_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, - comments_url: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, - statuses_url: { - default: 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', - }, - locked: { default: false }, - title: { default: 'New feature' }, - body: { default: 'Please merge thx' }, - user: { linked: UserBuilder }, - author_association: { default: 'OWNER' }, - assignee: { linked: UserBuilder }, - assignees: { default: [] }, - requested_reviewers: { default: [] }, - requested_teams: { default: [] }, - labels: { default: [] }, - active_lock_reason: { default: '' }, - created_at: { default: '2019-01-01T08:00:00Z' }, - updated_at: { default: '2019-01-01T08:00:00Z' }, - closed_at: { default: '' }, - merged_at: { default: '' }, - merge_commit_sha: { default: '' }, - head: { linked: RefBuilder }, - base: { linked: RefBuilder }, - draft: { default: false }, - merged: { default: false }, - mergeable: { default: true }, - rebaseable: { default: true }, - mergeable_state: { default: 'clean' }, - review_comments: { default: 0 }, - merged_by: { linked: UserBuilder }, - comments: { default: 10 }, - commits: { default: 5 }, - additions: { default: 3 }, - deletions: { default: 400 }, - changed_files: { default: 10 }, - maintainer_can_modify: { default: true }, - milestone: createLink()({ - id: { default: 1 }, - node_id: { default: 'milestone0' }, - number: { default: 100 }, - state: { default: 'open' }, - title: { default: 'milestone title' }, - description: { default: 'milestone description' }, - url: { default: 'https://api.github.com/repos/octocat/reponame/milestones/100' }, - html_url: { default: 'https://github.com/octocat/reponame/milestones/123' }, - labels_url: { default: 'https://github.com/octocat/reponame/milestones/123/labels' }, - creator: { linked: UserBuilder }, - open_issues: { default: 10 }, - closed_issues: { default: 5 }, - created_at: { default: '2019-01-01T10:00:00Z' }, - updated_at: { default: '2019-01-01T10:00:00Z' }, - closed_at: { default: '2019-01-01T10:00:00Z' }, - due_on: { default: '2019-01-01T10:00:00Z' }, - }), - _links: createLink()({ - self: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' }, - }), - html: createLink()({ - href: { default: 'https://github.com/octocat/reponame/pull/1347' }, - }), - issue: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' }, - }), - comments: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, - }), - review_comments: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, - }), - review_comment: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, - }), - commits: createLink()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' }, - }), - statuses: createLink()({ - href: { - default: - 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', - }, - }), - }), - auto_merge: { default: null }, -}); - -export type PullRequestBuilder = InstanceType; +import { UserBuilder } from './userBuilder'; +import { RefBuilder } from './refBuilder'; +import { createLink, createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + + +export type PullRequestUnion = OctokitCommon.PullsGetResponseData; +type Links = PullRequestUnion['_links']; +type Milestone = PullRequestUnion['milestone']; + +export const PullRequestBuilder = createBuilderClass()({ + id: { default: 0 }, + node_id: { default: 'node0' }, + number: { default: 1347 }, + url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' }, + state: { default: 'open' }, + html_url: { default: 'https://github.com/octocat/reponame/pull/1347' }, + diff_url: { default: 'https://github.com/octocat/reponame/pull/1347.diff' }, + patch_url: { default: 'https://github.com/octocat/reponame/pull/1347.patch' }, + issue_url: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' }, + commits_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' }, + review_comments_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, + review_comment_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, + comments_url: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, + statuses_url: { + default: 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }, + locked: { default: false }, + title: { default: 'New feature' }, + body: { default: 'Please merge thx' }, + user: { linked: UserBuilder }, + author_association: { default: 'OWNER' }, + assignee: { linked: UserBuilder }, + assignees: { default: [] }, + requested_reviewers: { default: [] }, + requested_teams: { default: [] }, + labels: { default: [] }, + active_lock_reason: { default: '' }, + created_at: { default: '2019-01-01T08:00:00Z' }, + updated_at: { default: '2019-01-01T08:00:00Z' }, + closed_at: { default: '' }, + merged_at: { default: '' }, + merge_commit_sha: { default: '' }, + head: { linked: RefBuilder }, + base: { linked: RefBuilder }, + draft: { default: false }, + merged: { default: false }, + mergeable: { default: true }, + rebaseable: { default: true }, + mergeable_state: { default: 'clean' }, + review_comments: { default: 0 }, + merged_by: { linked: UserBuilder }, + comments: { default: 10 }, + commits: { default: 5 }, + additions: { default: 3 }, + deletions: { default: 400 }, + changed_files: { default: 10 }, + maintainer_can_modify: { default: true }, + milestone: createLink()({ + id: { default: 1 }, + node_id: { default: 'milestone0' }, + number: { default: 100 }, + state: { default: 'open' }, + title: { default: 'milestone title' }, + description: { default: 'milestone description' }, + url: { default: 'https://api.github.com/repos/octocat/reponame/milestones/100' }, + html_url: { default: 'https://github.com/octocat/reponame/milestones/123' }, + labels_url: { default: 'https://github.com/octocat/reponame/milestones/123/labels' }, + creator: { linked: UserBuilder }, + open_issues: { default: 10 }, + closed_issues: { default: 5 }, + created_at: { default: '2019-01-01T10:00:00Z' }, + updated_at: { default: '2019-01-01T10:00:00Z' }, + closed_at: { default: '2019-01-01T10:00:00Z' }, + due_on: { default: '2019-01-01T10:00:00Z' }, + }), + _links: createLink()({ + self: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' }, + }), + html: createLink()({ + href: { default: 'https://github.com/octocat/reponame/pull/1347' }, + }), + issue: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' }, + }), + comments: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, + }), + review_comments: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, + }), + review_comment: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, + }), + commits: createLink()({ + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' }, + }), + statuses: createLink()({ + href: { + default: + 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }, + }), + }), + auto_merge: { default: null }, +}); + +export type PullRequestBuilder = InstanceType; diff --git a/src/test/builders/rest/refBuilder.ts b/src/test/builders/rest/refBuilder.ts index 6796e9a19f..7c95ef3e80 100644 --- a/src/test/builders/rest/refBuilder.ts +++ b/src/test/builders/rest/refBuilder.ts @@ -1,17 +1,18 @@ -import { UserBuilder } from './userBuilder'; -import { RepositoryBuilder } from './repoBuilder'; -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -type RefUnion = OctokitCommon.PullsListResponseItemHead & OctokitCommon.PullsListResponseItemBase; - -export const RefBuilder = createBuilderClass()({ - label: { default: 'octocat:new-feature' }, - ref: { default: 'new-feature' }, - user: { linked: UserBuilder }, - sha: { default: '0000000000000000000000000000000000000000' }, - // Must cast to any here to prevent recursive type. - repo: { linked: RepositoryBuilder }, -}); - -export type RefBuilder = InstanceType; +import { UserBuilder } from './userBuilder'; +import { RepositoryBuilder } from './repoBuilder'; +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + + +type RefUnion = OctokitCommon.PullsListResponseItemHead & OctokitCommon.PullsListResponseItemBase; + +export const RefBuilder = createBuilderClass()({ + label: { default: 'octocat:new-feature' }, + ref: { default: 'new-feature' }, + user: { linked: UserBuilder }, + sha: { default: '0000000000000000000000000000000000000000' }, + // Must cast to any here to prevent recursive type. + repo: { linked: RepositoryBuilder }, +}); + +export type RefBuilder = InstanceType; diff --git a/src/test/builders/rest/repoBuilder.ts b/src/test/builders/rest/repoBuilder.ts index 60174c4c96..49eacd3d5e 100644 --- a/src/test/builders/rest/repoBuilder.ts +++ b/src/test/builders/rest/repoBuilder.ts @@ -1,126 +1,127 @@ -import { UserBuilder } from './userBuilder'; -import { OrganizationBuilder } from './organizationBuilder'; -import { createBuilderClass, createLink } from '../base'; -import { OctokitCommon } from '../../../github/common'; -import { ForkDetails } from '../../../github/githubRepository'; - -export type RepoUnion = OctokitCommon.ReposGetResponseData & - OctokitCommon.PullsListResponseItemHeadRepo & - OctokitCommon.PullsListResponseItemBaseRepo; - -type License = RepoUnion['license']; -type Permissions = RepoUnion['permissions']; -type CodeOfConduct = RepoUnion['code_of_conduct']; - -export const RepositoryBuilder = createBuilderClass()({ - id: { default: 0 }, - node_id: { default: 'node0' }, - name: { default: 'reponame' }, - full_name: { default: 'octocat/reponame' }, - description: { default: 'The default repo' }, - owner: { linked: UserBuilder }, - organization: { linked: OrganizationBuilder }, - parent: { default: null } as any, // These are absent when the repo is not a fork. - source: { default: null } as any, - private: { default: false }, - fork: { default: false }, - disabled: { default: false }, - url: { default: 'https://api.github.com/repos/octocat/reponame' }, - html_url: { default: 'https://github.com/octocat/reponame' }, - archive_url: { default: 'http://api.github.com/repos/octocat/reponame/{archive_format}{/ref}' }, - assignees_url: { default: 'http://api.github.com/repos/octocat/reponame/assignees{/user}' }, - blobs_url: { default: 'http://api.github.com/repos/octocat/reponame/git/blobs{/sha}' }, - branches_url: { default: 'http://api.github.com/repos/octocat/reponame/branches{/branch}' }, - collaborators_url: { default: 'http://api.github.com/repos/octocat/reponame/collaborators{/collaborator}' }, - comments_url: { default: 'http://api.github.com/repos/octocat/reponame/comments{/number}' }, - commits_url: { default: 'http://api.github.com/repos/octocat/reponame/commits{/sha}' }, - compare_url: { default: 'http://api.github.com/repos/octocat/reponame/compare/{base}...{head}' }, - contents_url: { default: 'http://api.github.com/repos/octocat/reponame/contents/{+path}' }, - contributors_url: { default: 'http://api.github.com/repos/octocat/reponame/contributors' }, - deployments_url: { default: 'http://api.github.com/repos/octocat/reponame/deployments' }, - downloads_url: { default: 'http://api.github.com/repos/octocat/reponame/downloads' }, - events_url: { default: 'http://api.github.com/repos/octocat/reponame/events' }, - forks_url: { default: 'http://api.github.com/repos/octocat/reponame/forks' }, - git_commits_url: { default: 'http://api.github.com/repos/octocat/reponame/git/commits{/sha}' }, - git_refs_url: { default: 'http://api.github.com/repos/octocat/reponame/git/refs{/sha}' }, - git_tags_url: { default: 'http://api.github.com/repos/octocat/reponame/git/tags{/sha}' }, - git_url: { default: 'git:github.com/octocat/reponame.git' }, - issue_comment_url: { default: 'http://api.github.com/repos/octocat/reponame/issues/comments{/number}' }, - issue_events_url: { default: 'http://api.github.com/repos/octocat/reponame/issues/events{/number}' }, - issues_url: { default: 'http://api.github.com/repos/octocat/reponame/issues{/number}' }, - keys_url: { default: 'http://api.github.com/repos/octocat/reponame/keys{/key_id}' }, - labels_url: { default: 'http://api.github.com/repos/octocat/reponame/labels{/name}' }, - languages_url: { default: 'http://api.github.com/repos/octocat/reponame/languages' }, - merges_url: { default: 'http://api.github.com/repos/octocat/reponame/merges' }, - milestones_url: { default: 'http://api.github.com/repos/octocat/reponame/milestones{/number}' }, - notifications_url: { - default: 'http://api.github.com/repos/octocat/reponame/notifications{?since,all,participating}', - }, - pulls_url: { default: 'http://api.github.com/repos/octocat/reponame/pulls{/number}' }, - releases_url: { default: 'http://api.github.com/repos/octocat/reponame/releases{/id}' }, - ssh_url: { default: 'git@github.com:octocat/reponame.git' }, - stargazers_url: { default: 'http://api.github.com/repos/octocat/reponame/stargazers' }, - statuses_url: { default: 'http://api.github.com/repos/octocat/reponame/statuses/{sha}' }, - subscribers_url: { default: 'http://api.github.com/repos/octocat/reponame/subscribers' }, - subscription_url: { default: 'http://api.github.com/repos/octocat/reponame/subscription' }, - tags_url: { default: 'http://api.github.com/repos/octocat/reponame/tags' }, - teams_url: { default: 'http://api.github.com/repos/octocat/reponame/teams' }, - trees_url: { default: 'http://api.github.com/repos/octocat/reponame/git/trees{/sha}' }, - clone_url: { default: 'https://github.com/octocat/reponame.git' }, - mirror_url: { default: 'git:git.example.com/octocat/reponame' }, - hooks_url: { default: 'http://api.github.com/repos/octocat/reponame/hooks' }, - svn_url: { default: 'https://svn.github.com/octocat/reponame' }, - homepage: { default: 'https://github.com' }, - language: { default: '' }, - license: createLink()({ - key: { default: 'mit' }, - name: { default: 'MIT License' }, - spdx_id: { default: 'MIT' }, - url: { default: 'https://api.github.com/licenses/mit' }, - node_id: { default: 'MDc6TGljZW5zZW1pdA==' }, - }), - forks_count: { default: 9 }, - stargazers_count: { default: 80 }, - watchers_count: { default: 80 }, - size: { default: 108 }, - default_branch: { default: 'main' }, - open_issues_count: { default: 0 }, - topics: { default: [] }, - has_issues: { default: true }, - has_projects: { default: true }, - has_wiki: { default: true }, - has_pages: { default: false }, - has_downloads: { default: true }, - archived: { default: false }, - pushed_at: { default: '2011-01-26T19:06:43Z' }, - created_at: { default: '2011-01-26T19:01:12Z' }, - updated_at: { default: '2011-01-26T19:14:43Z' }, - permissions: createLink()({ - admin: { default: false }, - push: { default: false }, - pull: { default: true }, - maintain: { default: false }, - triage: { default: false }, - }), - allow_rebase_merge: { default: true }, - allow_squash_merge: { default: true }, - allow_merge_commit: { default: true }, - subscribers_count: { default: 42 }, - network_count: { default: 0 }, - is_template: { default: false }, - temp_clone_token: { default: '' }, - template_repository: { default: '' }, - visibility: { default: '' }, - delete_branch_on_merge: { default: false }, - code_of_conduct: createLink()({ - html_url: { default: 'https://github.com/octocat/reponame' }, - key: { default: 'key' }, - name: { default: 'name' }, - url: { default: 'https://github.com/octocat/reponame' }, - }), - forks: { default: null }, - open_issues: { default: null }, - watchers: { default: null }, -}); - -export type RepositoryBuilder = InstanceType; +import { UserBuilder } from './userBuilder'; +import { OrganizationBuilder } from './organizationBuilder'; +import { createBuilderClass, createLink } from '../base'; +import { OctokitCommon } from '../../../github/common'; +import { ForkDetails } from '../../../github/githubRepository'; + + +export type RepoUnion = OctokitCommon.ReposGetResponseData & + OctokitCommon.PullsListResponseItemHeadRepo & + OctokitCommon.PullsListResponseItemBaseRepo; + +type License = RepoUnion['license']; +type Permissions = RepoUnion['permissions']; +type CodeOfConduct = RepoUnion['code_of_conduct']; + +export const RepositoryBuilder = createBuilderClass()({ + id: { default: 0 }, + node_id: { default: 'node0' }, + name: { default: 'reponame' }, + full_name: { default: 'octocat/reponame' }, + description: { default: 'The default repo' }, + owner: { linked: UserBuilder }, + organization: { linked: OrganizationBuilder }, + parent: { default: null } as any, // These are absent when the repo is not a fork. + source: { default: null } as any, + private: { default: false }, + fork: { default: false }, + disabled: { default: false }, + url: { default: 'https://api.github.com/repos/octocat/reponame' }, + html_url: { default: 'https://github.com/octocat/reponame' }, + archive_url: { default: 'http://api.github.com/repos/octocat/reponame/{archive_format}{/ref}' }, + assignees_url: { default: 'http://api.github.com/repos/octocat/reponame/assignees{/user}' }, + blobs_url: { default: 'http://api.github.com/repos/octocat/reponame/git/blobs{/sha}' }, + branches_url: { default: 'http://api.github.com/repos/octocat/reponame/branches{/branch}' }, + collaborators_url: { default: 'http://api.github.com/repos/octocat/reponame/collaborators{/collaborator}' }, + comments_url: { default: 'http://api.github.com/repos/octocat/reponame/comments{/number}' }, + commits_url: { default: 'http://api.github.com/repos/octocat/reponame/commits{/sha}' }, + compare_url: { default: 'http://api.github.com/repos/octocat/reponame/compare/{base}...{head}' }, + contents_url: { default: 'http://api.github.com/repos/octocat/reponame/contents/{+path}' }, + contributors_url: { default: 'http://api.github.com/repos/octocat/reponame/contributors' }, + deployments_url: { default: 'http://api.github.com/repos/octocat/reponame/deployments' }, + downloads_url: { default: 'http://api.github.com/repos/octocat/reponame/downloads' }, + events_url: { default: 'http://api.github.com/repos/octocat/reponame/events' }, + forks_url: { default: 'http://api.github.com/repos/octocat/reponame/forks' }, + git_commits_url: { default: 'http://api.github.com/repos/octocat/reponame/git/commits{/sha}' }, + git_refs_url: { default: 'http://api.github.com/repos/octocat/reponame/git/refs{/sha}' }, + git_tags_url: { default: 'http://api.github.com/repos/octocat/reponame/git/tags{/sha}' }, + git_url: { default: 'git:github.com/octocat/reponame.git' }, + issue_comment_url: { default: 'http://api.github.com/repos/octocat/reponame/issues/comments{/number}' }, + issue_events_url: { default: 'http://api.github.com/repos/octocat/reponame/issues/events{/number}' }, + issues_url: { default: 'http://api.github.com/repos/octocat/reponame/issues{/number}' }, + keys_url: { default: 'http://api.github.com/repos/octocat/reponame/keys{/key_id}' }, + labels_url: { default: 'http://api.github.com/repos/octocat/reponame/labels{/name}' }, + languages_url: { default: 'http://api.github.com/repos/octocat/reponame/languages' }, + merges_url: { default: 'http://api.github.com/repos/octocat/reponame/merges' }, + milestones_url: { default: 'http://api.github.com/repos/octocat/reponame/milestones{/number}' }, + notifications_url: { + default: 'http://api.github.com/repos/octocat/reponame/notifications{?since,all,participating}', + }, + pulls_url: { default: 'http://api.github.com/repos/octocat/reponame/pulls{/number}' }, + releases_url: { default: 'http://api.github.com/repos/octocat/reponame/releases{/id}' }, + ssh_url: { default: 'git@github.com:octocat/reponame.git' }, + stargazers_url: { default: 'http://api.github.com/repos/octocat/reponame/stargazers' }, + statuses_url: { default: 'http://api.github.com/repos/octocat/reponame/statuses/{sha}' }, + subscribers_url: { default: 'http://api.github.com/repos/octocat/reponame/subscribers' }, + subscription_url: { default: 'http://api.github.com/repos/octocat/reponame/subscription' }, + tags_url: { default: 'http://api.github.com/repos/octocat/reponame/tags' }, + teams_url: { default: 'http://api.github.com/repos/octocat/reponame/teams' }, + trees_url: { default: 'http://api.github.com/repos/octocat/reponame/git/trees{/sha}' }, + clone_url: { default: 'https://github.com/octocat/reponame.git' }, + mirror_url: { default: 'git:git.example.com/octocat/reponame' }, + hooks_url: { default: 'http://api.github.com/repos/octocat/reponame/hooks' }, + svn_url: { default: 'https://svn.github.com/octocat/reponame' }, + homepage: { default: 'https://github.com' }, + language: { default: '' }, + license: createLink()({ + key: { default: 'mit' }, + name: { default: 'MIT License' }, + spdx_id: { default: 'MIT' }, + url: { default: 'https://api.github.com/licenses/mit' }, + node_id: { default: 'MDc6TGljZW5zZW1pdA==' }, + }), + forks_count: { default: 9 }, + stargazers_count: { default: 80 }, + watchers_count: { default: 80 }, + size: { default: 108 }, + default_branch: { default: 'main' }, + open_issues_count: { default: 0 }, + topics: { default: [] }, + has_issues: { default: true }, + has_projects: { default: true }, + has_wiki: { default: true }, + has_pages: { default: false }, + has_downloads: { default: true }, + archived: { default: false }, + pushed_at: { default: '2011-01-26T19:06:43Z' }, + created_at: { default: '2011-01-26T19:01:12Z' }, + updated_at: { default: '2011-01-26T19:14:43Z' }, + permissions: createLink()({ + admin: { default: false }, + push: { default: false }, + pull: { default: true }, + maintain: { default: false }, + triage: { default: false }, + }), + allow_rebase_merge: { default: true }, + allow_squash_merge: { default: true }, + allow_merge_commit: { default: true }, + subscribers_count: { default: 42 }, + network_count: { default: 0 }, + is_template: { default: false }, + temp_clone_token: { default: '' }, + template_repository: { default: '' }, + visibility: { default: '' }, + delete_branch_on_merge: { default: false }, + code_of_conduct: createLink()({ + html_url: { default: 'https://github.com/octocat/reponame' }, + key: { default: 'key' }, + name: { default: 'name' }, + url: { default: 'https://github.com/octocat/reponame' }, + }), + forks: { default: null }, + open_issues: { default: null }, + watchers: { default: null }, +}); + +export type RepositoryBuilder = InstanceType; diff --git a/src/test/builders/rest/reviewRequestsBuilder.ts b/src/test/builders/rest/reviewRequestsBuilder.ts index bd7f3e4ab2..d64f510c2a 100644 --- a/src/test/builders/rest/reviewRequestsBuilder.ts +++ b/src/test/builders/rest/reviewRequestsBuilder.ts @@ -1,10 +1,11 @@ -import { OctokitCommon } from '../../../github/common'; - -import { createBuilderClass } from '../base'; - -export const ReviewRequestsBuilder = createBuilderClass()({ - users: { default: [] }, - teams: { default: [] }, -}); - -export type ReviewRequestsBuilder = InstanceType; +import { OctokitCommon } from '../../../github/common'; + +import { createBuilderClass } from '../base'; + +export const ReviewRequestsBuilder = createBuilderClass()({ + + users: { default: [] }, + teams: { default: [] }, +}); + +export type ReviewRequestsBuilder = InstanceType; diff --git a/src/test/builders/rest/teamBuilder.ts b/src/test/builders/rest/teamBuilder.ts index 2d17cd0447..8c89c28f06 100644 --- a/src/test/builders/rest/teamBuilder.ts +++ b/src/test/builders/rest/teamBuilder.ts @@ -1,21 +1,22 @@ -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -export type TeamUnion = OctokitCommon.PullsListReviewRequestsResponseTeamsItem; - -export const TeamBuilder = createBuilderClass()({ - id: { default: 1 }, - node_id: { default: 'MDQ6VGVhbTE=' }, - url: { default: 'https://api.github.com/teams/1' }, - name: { default: 'Justice League' }, - slug: { default: 'justice-league' }, - description: { default: 'A great team.' }, - privacy: { default: 'closed' }, - permission: { default: 'admin' }, - members_url: { default: 'https://api.github.com/teams/1/members{/member}' }, - repositories_url: { default: 'https://api.github.com/teams/1/repos' }, - html_url: { default: 'https://api.github.com/teams/1' }, - ldap_dn: { default: '' }, -}); - -export type TeamBuilder = InstanceType; +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + +export type TeamUnion = OctokitCommon.PullsListReviewRequestsResponseTeamsItem; + + +export const TeamBuilder = createBuilderClass()({ + id: { default: 1 }, + node_id: { default: 'MDQ6VGVhbTE=' }, + url: { default: 'https://api.github.com/teams/1' }, + name: { default: 'Justice League' }, + slug: { default: 'justice-league' }, + description: { default: 'A great team.' }, + privacy: { default: 'closed' }, + permission: { default: 'admin' }, + members_url: { default: 'https://api.github.com/teams/1/members{/member}' }, + repositories_url: { default: 'https://api.github.com/teams/1/repos' }, + html_url: { default: 'https://api.github.com/teams/1' }, + ldap_dn: { default: '' }, +}); + +export type TeamBuilder = InstanceType; diff --git a/src/test/builders/rest/timelineEventItemBuilder.ts b/src/test/builders/rest/timelineEventItemBuilder.ts index ec3510632b..3ee366dc4c 100644 --- a/src/test/builders/rest/timelineEventItemBuilder.ts +++ b/src/test/builders/rest/timelineEventItemBuilder.ts @@ -1,31 +1,32 @@ -import { UserBuilder } from './userBuilder'; -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -export const TimelineEventItemBuilder = createBuilderClass()({ - id: { default: 1 }, - node_id: { default: 'MDEwOklzc3VlRXZlbnQx' }, - url: { default: 'https://api.github.com/repos/octocat/Hello-World/issues/events/1' }, - actor: { linked: UserBuilder }, - event: { default: 'closed' }, - commit_id: { default: '0000000000000000000000000000000000000000' }, - commit_url: { - default: 'https://api.github.com/repos/octocat/Hello-World/commits/0000000000000000000000000000000000000000', - }, - created_at: { default: '2019-01-01T10:00:00Z' }, - sha: { default: '00000000000000000000000000000000' }, - author_association: { default: 'COLLABORATOR' }, - body: { default: '' }, - body_html: { default: '' }, - body_text: { default: '' }, - html_url: { default: 'https://github.com/octocat' }, - issue_url: { default: 'https://github.com/octocat/issues/1' }, - lock_reason: { default: '' }, - message: { default: '' }, - pull_request_url: { default: 'https://github.com/octocat/pulls/1' }, - state: { default: '' }, - submitted_at: { default: '' }, - updated_at: { default: '' }, -}); - -export type TimelineEventItemBuilder = InstanceType; +import { UserBuilder } from './userBuilder'; +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + +export const TimelineEventItemBuilder = createBuilderClass()({ + + id: { default: 1 }, + node_id: { default: 'MDEwOklzc3VlRXZlbnQx' }, + url: { default: 'https://api.github.com/repos/octocat/Hello-World/issues/events/1' }, + actor: { linked: UserBuilder }, + event: { default: 'closed' }, + commit_id: { default: '0000000000000000000000000000000000000000' }, + commit_url: { + default: 'https://api.github.com/repos/octocat/Hello-World/commits/0000000000000000000000000000000000000000', + }, + created_at: { default: '2019-01-01T10:00:00Z' }, + sha: { default: '00000000000000000000000000000000' }, + author_association: { default: 'COLLABORATOR' }, + body: { default: '' }, + body_html: { default: '' }, + body_text: { default: '' }, + html_url: { default: 'https://github.com/octocat' }, + issue_url: { default: 'https://github.com/octocat/issues/1' }, + lock_reason: { default: '' }, + message: { default: '' }, + pull_request_url: { default: 'https://github.com/octocat/pulls/1' }, + state: { default: '' }, + submitted_at: { default: '' }, + updated_at: { default: '' }, +}); + +export type TimelineEventItemBuilder = InstanceType; diff --git a/src/test/builders/rest/userBuilder.ts b/src/test/builders/rest/userBuilder.ts index 377be7ec78..899aacd628 100644 --- a/src/test/builders/rest/userBuilder.ts +++ b/src/test/builders/rest/userBuilder.ts @@ -1,37 +1,38 @@ -import { createBuilderClass } from '../base'; -import { OctokitCommon } from '../../../github/common'; - -type UserUnion = - | OctokitCommon.PullsListResponseItemUser - | OctokitCommon.PullsListResponseItemAssignee - | OctokitCommon.PullsListResponseItemAssigneesItem - | OctokitCommon.PullsListResponseItemRequestedReviewersItem - | OctokitCommon.PullsListResponseItemBaseUser - | OctokitCommon.PullsListResponseItemBaseRepoOwner - | OctokitCommon.PullsListResponseItemHeadUser - | OctokitCommon.PullsListResponseItemHeadRepoOwner - | OctokitCommon.IssuesListEventsForTimelineResponseItemActor; - -export const UserBuilder = createBuilderClass>()({ - id: { default: 0 }, - node_id: { default: 'node0' }, - login: { default: 'octocat' }, - avatar_url: { default: 'https://avatars0.githubusercontent.com/u/583231?v=4' }, - gravatar_id: { default: '' }, - url: { default: 'https://api.github.com/users/octocat' }, - html_url: { default: 'https://github.com/octocat' }, - followers_url: { default: 'https://api.github.com/users/octocat/followers' }, - following_url: { default: 'https://api.github.com/users/octocat/following{/other_user}' }, - gists_url: { default: 'https://api.github.com/users/octocat/gists{/gist_id}' }, - starred_url: { default: 'https://api.github.com/users/octocat/starred{/owner}{/repo}' }, - subscriptions_url: { default: 'https://api.github.com/users/octocat/subscriptions' }, - organizations_url: { default: 'https://api.github.com/users/octocat/orgs' }, - repos_url: { default: 'https://api.github.com/users/octocat/repos' }, - events_url: { default: 'https://api.github.com/users/octocat/events{/privacy}' }, - received_events_url: { default: 'https://api.github.com/users/octocat/received_events' }, - type: { default: 'User' }, - site_admin: { default: false }, - starred_at: { default: '' }, -}); - -export type UserBuilder = InstanceType; +import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; + +type UserUnion = + | OctokitCommon.PullsListResponseItemUser + + | OctokitCommon.PullsListResponseItemAssignee + | OctokitCommon.PullsListResponseItemAssigneesItem + | OctokitCommon.PullsListResponseItemRequestedReviewersItem + | OctokitCommon.PullsListResponseItemBaseUser + | OctokitCommon.PullsListResponseItemBaseRepoOwner + | OctokitCommon.PullsListResponseItemHeadUser + | OctokitCommon.PullsListResponseItemHeadRepoOwner + | OctokitCommon.IssuesListEventsForTimelineResponseItemActor; + +export const UserBuilder = createBuilderClass>()({ + id: { default: 0 }, + node_id: { default: 'node0' }, + login: { default: 'octocat' }, + avatar_url: { default: 'https://avatars0.githubusercontent.com/u/583231?v=4' }, + gravatar_id: { default: '' }, + url: { default: 'https://api.github.com/users/octocat' }, + html_url: { default: 'https://github.com/octocat' }, + followers_url: { default: 'https://api.github.com/users/octocat/followers' }, + following_url: { default: 'https://api.github.com/users/octocat/following{/other_user}' }, + gists_url: { default: 'https://api.github.com/users/octocat/gists{/gist_id}' }, + starred_url: { default: 'https://api.github.com/users/octocat/starred{/owner}{/repo}' }, + subscriptions_url: { default: 'https://api.github.com/users/octocat/subscriptions' }, + organizations_url: { default: 'https://api.github.com/users/octocat/orgs' }, + repos_url: { default: 'https://api.github.com/users/octocat/repos' }, + events_url: { default: 'https://api.github.com/users/octocat/events{/privacy}' }, + received_events_url: { default: 'https://api.github.com/users/octocat/received_events' }, + type: { default: 'User' }, + site_admin: { default: false }, + starred_at: { default: '' }, +}); + +export type UserBuilder = InstanceType; diff --git a/src/test/common/commentingRanges.test.ts b/src/test/common/commentingRanges.test.ts index 9d389839b6..181af4c428 100644 --- a/src/test/common/commentingRanges.test.ts +++ b/src/test/common/commentingRanges.test.ts @@ -1,82 +1,83 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { getCommentingRanges } from '../../common/commentingRanges'; -import { parsePatch } from '../../common/diffHunk'; - -const patch = [ - `@@ -8,6 +8,7 @@ import { Terminal } from './Terminal';`, - ` import { MockViewport, MockCompositionHelper, MockRenderer } from './TestUtils.test';`, - ` import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';`, - ` import { CellData } from 'common/buffer/CellData';`, - `+import { wcwidth } from 'common/CharWidth';`, - ` `, - ` const INIT_COLS = 80;`, - ` const INIT_ROWS = 24;`, - `@@ -750,10 +751,14 @@ describe('Terminal', () => {`, - ` for (let i = 0xDC00; i <= 0xDCFF; ++i) {`, - ` term.buffer.x = term.cols - 1;`, - ` term.wraparoundMode = false;`, - `+ const width = wcwidth((0xD800 - 0xD800) * 0x400 + i - 0xDC00 + 0x10000);`, - `+ if (width !== 1) {`, - `+ continue;`, - `+ }`, - ` term.write('a' + high + String.fromCharCode(i));`, - ` // auto wraparound mode should cut off the rest of the line`, - `- expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars()).eql('a');`, - `- expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars().length).eql(1);`, - `+ expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars()).eql(high + String.fromCharCode(i));`, - `+ expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars().length).eql(2);`, - ` expect(term.buffer.lines.get(1).loadCell(1, cell).getChars()).eql('');`, - ` term.reset();`, - ` }` -].join('\n'); - -const deletionPatch = [ - `@@ -1,5 +0,0 @@`, - `-var express = require('express');`, - `-var path = require('path');`, - `-var favicon = require('serve-favicon');`, - `-var logger = require('morgan');`, - `-var cookieParser = require('cookie-parser');`, -].join('\n'); - -const diffHunks = parsePatch(patch); - -describe('getCommentingRanges', () => { - it('should return only ranges for deleted lines, mapped to full file, for the base file', () => { - const commentingRanges = getCommentingRanges(diffHunks, true); - assert.strictEqual(commentingRanges.length, 1); - assert.strictEqual(commentingRanges[0].start.line, 754); - assert.strictEqual(commentingRanges[0].start.character, 0); - assert.strictEqual(commentingRanges[0].end.line, 755); - assert.strictEqual(commentingRanges[0].end.character, 0); - }); - - it('should return only ranges for changes, mapped to full file, for the modified file', () => { - const commentingRanges = getCommentingRanges(diffHunks, false); - assert.strictEqual(commentingRanges.length, 2); - assert.strictEqual(commentingRanges[0].start.line, 7); - assert.strictEqual(commentingRanges[0].start.character, 0); - assert.strictEqual(commentingRanges[0].end.line, 13); - assert.strictEqual(commentingRanges[0].end.character, 0); - - assert.strictEqual(commentingRanges[1].start.line, 750); - assert.strictEqual(commentingRanges[1].start.character, 0); - assert.strictEqual(commentingRanges[1].end.line, 763); - assert.strictEqual(commentingRanges[1].end.character, 0); - }); - - it('should handle the last part of the diff being a deletion, for the base file', () => { - const diffHunksForDeletion = parsePatch(deletionPatch); - const commentingRanges = getCommentingRanges(diffHunksForDeletion, true); - assert.strictEqual(commentingRanges.length, 1); - assert.strictEqual(commentingRanges[0].start.line, 0); - assert.strictEqual(commentingRanges[0].start.character, 0); - assert.strictEqual(commentingRanges[0].end.line, 4); - assert.strictEqual(commentingRanges[0].end.character, 0); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { getCommentingRanges } from '../../common/commentingRanges'; +import { parsePatch } from '../../common/diffHunk'; + +const patch = [ + `@@ -8,6 +8,7 @@ import { Terminal } from './Terminal';`, + ` import { MockViewport, MockCompositionHelper, MockRenderer } from './TestUtils.test';`, + ` import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';`, + ` import { CellData } from 'common/buffer/CellData';`, + `+import { wcwidth } from 'common/CharWidth';`, + ` `, + ` const INIT_COLS = 80;`, + ` const INIT_ROWS = 24;`, + `@@ -750,10 +751,14 @@ describe('Terminal', () => {`, + ` for (let i = 0xDC00; i <= 0xDCFF; ++i) {`, + ` term.buffer.x = term.cols - 1;`, + ` term.wraparoundMode = false;`, + `+ const width = wcwidth((0xD800 - 0xD800) * 0x400 + i - 0xDC00 + 0x10000);`, + `+ if (width !== 1) {`, + `+ continue;`, + `+ }`, + ` term.write('a' + high + String.fromCharCode(i));`, + ` // auto wraparound mode should cut off the rest of the line`, + `- expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars()).eql('a');`, + `- expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars().length).eql(1);`, + `+ expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars()).eql(high + String.fromCharCode(i));`, + `+ expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars().length).eql(2);`, + ` expect(term.buffer.lines.get(1).loadCell(1, cell).getChars()).eql('');`, + ` term.reset();`, + ` }` +].join('\n'); + +const deletionPatch = [ + `@@ -1,5 +0,0 @@`, + `-var express = require('express');`, + `-var path = require('path');`, + `-var favicon = require('serve-favicon');`, + `-var logger = require('morgan');`, + `-var cookieParser = require('cookie-parser');`, +].join('\n'); + +const diffHunks = parsePatch(patch); + +describe('getCommentingRanges', () => { + it('should return only ranges for deleted lines, mapped to full file, for the base file', () => { + const commentingRanges = getCommentingRanges(diffHunks, true); + assert.strictEqual(commentingRanges.length, 1); + assert.strictEqual(commentingRanges[0].start.line, 754); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 755); + assert.strictEqual(commentingRanges[0].end.character, 0); + }); + + it('should return only ranges for changes, mapped to full file, for the modified file', () => { + const commentingRanges = getCommentingRanges(diffHunks, false); + assert.strictEqual(commentingRanges.length, 2); + assert.strictEqual(commentingRanges[0].start.line, 7); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 13); + assert.strictEqual(commentingRanges[0].end.character, 0); + + assert.strictEqual(commentingRanges[1].start.line, 750); + assert.strictEqual(commentingRanges[1].start.character, 0); + assert.strictEqual(commentingRanges[1].end.line, 763); + assert.strictEqual(commentingRanges[1].end.character, 0); + }); + + it('should handle the last part of the diff being a deletion, for the base file', () => { + const diffHunksForDeletion = parsePatch(deletionPatch); + const commentingRanges = getCommentingRanges(diffHunksForDeletion, true); + assert.strictEqual(commentingRanges.length, 1); + assert.strictEqual(commentingRanges[0].start.line, 0); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 4); + assert.strictEqual(commentingRanges[0].end.character, 0); + }); +}); diff --git a/src/test/common/diff.test.ts b/src/test/common/diff.test.ts index 15c91fb38b..7591fcd55b 100644 --- a/src/test/common/diff.test.ts +++ b/src/test/common/diff.test.ts @@ -1,232 +1,233 @@ -import { default as assert } from 'assert'; -import { parseDiffHunk, DiffHunk, getModifiedContentFromDiffHunk } from '../../common/diffHunk'; -import { DiffLine, DiffChangeType } from '../../common/diffHunk'; -import { - getDiffLineByPosition, -} from '../../common/diffPositionMapping'; - -const diff_hunk_0 = [ - `@@ -1,5 +1,6 @@`, - ` {`, - ` "appService.zipIgnorePattern": [`, - ` "node_modules{,/**}"`, - `- ]`, - `-}`, - `\\ No newline at end of file`, - `+ ],`, - `+ "editor.insertSpaces": false`, - `+}`, -].join('\n'); - -describe('diff hunk parsing', () => { - it('diffhunk iterator', () => { - const diffHunkReader = parseDiffHunk(diff_hunk_0); - const diffHunkIter = diffHunkReader.next(); - const diffHunk = diffHunkIter.value; - assert.strictEqual(diffHunk.diffLines.length, 9); - - assert.deepStrictEqual(diffHunk.diffLines[0], new DiffLine(DiffChangeType.Control, -1, -1, 0, `@@ -1,5 +1,6 @@`)); - assert.deepStrictEqual(diffHunk.diffLines[1], new DiffLine(DiffChangeType.Context, 1, 1, 1, ` {`)); - assert.deepStrictEqual( - diffHunk.diffLines[2], - new DiffLine(DiffChangeType.Context, 2, 2, 2, ` "appService.zipIgnorePattern": [`), - ); - assert.deepStrictEqual( - diffHunk.diffLines[3], - new DiffLine(DiffChangeType.Context, 3, 3, 3, ` "node_modules{,/**}"`), - ); - assert.deepStrictEqual(diffHunk.diffLines[4], new DiffLine(DiffChangeType.Delete, 4, -1, 4, `- ]`)); - assert.deepStrictEqual(diffHunk.diffLines[5], new DiffLine(DiffChangeType.Delete, 5, -1, 5, `-}`, false)); - assert.deepStrictEqual(diffHunk.diffLines[6], new DiffLine(DiffChangeType.Add, -1, 4, 7, `+ ],`)); - assert.deepStrictEqual( - diffHunk.diffLines[7], - new DiffLine(DiffChangeType.Add, -1, 5, 8, `+ "editor.insertSpaces": false`), - ); - assert.deepStrictEqual(diffHunk.diffLines[8], new DiffLine(DiffChangeType.Add, -1, 6, 9, `+}`)); - }); - - // #GH-2000 - it('should handle parsing diffs of diff patches', () => { - const diffDiffHunk = [ - '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844', - " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", - ' is detected by configure, but it is not fully functional.', - ' ', - '---- configure.orig 2019-12-21 15:43:09.000000000 -0500', - '-+++ configure 2019-12-21 15:45:31.000000000 -0500', - '-@@ -16791,6 +16791,24 @@', - '+--- configure.orig 2020-07-13 07:11:53.000000000 -0500', - '++++ configure 2020-07-15 10:20:09.000000000 -0500', - '+@@ -16837,6 +16837,24 @@', - ' volatile int val = 1;', - ' int main() {', - " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'", - ].join('\n'); - - const diffHunkReader = parseDiffHunk(diffDiffHunk); - const diffHunkIter = diffHunkReader.next(); - const diffHunk = diffHunkIter.value; - assert.strictEqual(diffHunk.diffLines.length, 13); - assert.strictEqual(diffHunk.newLength, 9); - assert.strictEqual(diffHunk.newLineNumber, 4); - assert.strictEqual(diffHunk.oldLength, 9); - assert.strictEqual(diffHunk.oldLineNumber, 4); - - assert.deepStrictEqual( - diffHunk.diffLines[0], - new DiffLine(DiffChangeType.Control, -1, -1, 0, '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[1], - new DiffLine( - DiffChangeType.Context, - 4, - 4, - 1, - " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", - ), - ); - assert.deepStrictEqual( - diffHunk.diffLines[2], - new DiffLine(DiffChangeType.Context, 5, 5, 2, ' is detected by configure, but it is not fully functional.'), - ); - assert.deepStrictEqual(diffHunk.diffLines[3], new DiffLine(DiffChangeType.Context, 6, 6, 3, ' ')); - assert.deepStrictEqual( - diffHunk.diffLines[4], - new DiffLine(DiffChangeType.Delete, 7, -1, 4, '---- configure.orig\t2019-12-21 15:43:09.000000000 -0500'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[5], - new DiffLine(DiffChangeType.Delete, 8, -1, 5, '-+++ configure\t2019-12-21 15:45:31.000000000 -0500'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[6], - new DiffLine(DiffChangeType.Delete, 9, -1, 6, '-@@ -16791,6 +16791,24 @@'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[7], - new DiffLine(DiffChangeType.Add, -1, 7, 7, '+--- configure.orig\t2020-07-13 07:11:53.000000000 -0500'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[8], - new DiffLine(DiffChangeType.Add, -1, 8, 8, '++++ configure\t2020-07-15 10:20:09.000000000 -0500'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[9], - new DiffLine(DiffChangeType.Add, -1, 9, 9, '+@@ -16837,6 +16837,24 @@'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[10], - new DiffLine(DiffChangeType.Context, 10, 10, 10, ' volatile int val = 1;'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[11], - new DiffLine(DiffChangeType.Context, 11, 11, 11, ' int main() {'), - ); - assert.deepStrictEqual( - diffHunk.diffLines[12], - new DiffLine(DiffChangeType.Context, 12, 12, 12, " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'"), - ); - }); - - it('getDiffLineByPosition', () => { - const diffHunkReader = parseDiffHunk(diff_hunk_0); - const diffHunkIter = diffHunkReader.next(); - const diffHunk = diffHunkIter.value; - - for (let i = 0; i < diffHunk.diffLines.length; i++) { - const diffLine = diffHunk.diffLines[i]; - assert.deepStrictEqual(getDiffLineByPosition([diffHunk], diffLine.positionInHunk), diffLine, `diff line ${i}`); - } - }); - - it('#239. Diff hunk parsing fails when line count for added content is omitted', () => { - const diffHunkReader = parseDiffHunk('@@ -0,0 +1 @@'); - const diffHunkIter = diffHunkReader.next(); - const diffHunk = diffHunkIter.value; - assert.strictEqual(diffHunk.diffLines.length, 1); - }); - - it('', () => { - const diffHunkReader = parseDiffHunk(`@@ -1 +1,5 @@ -# README -+ -+This is my readme -+ -+Another line"`); - const diffHunkIter = diffHunkReader.next(); - const diffHunk = diffHunkIter.value; - assert.strictEqual(diffHunk.diffLines.length, 5); - }); - - describe('getModifiedContentFromDiffHunk', () => { - const originalContent = [ - `/*---------------------------------------------------------------------------------------------`, - `* Copyright (c) Microsoft Corporation. All rights reserved.`, - `* Licensed under the MIT License. See License.txt in the project root for license information.`, - `*--------------------------------------------------------------------------------------------*/`, - ``, - `'use strict';`, - ``, - `import { window, commands, ExtensionContext } from 'vscode';`, - `import { showQuickPick, showInputBox } from './basicInput';`, - `import { multiStepInput } from './multiStepInput';`, - `import { quickOpen } from './quickOpen';`, - ``, - `export function activate(context: ExtensionContext) {`, - ` context.subscriptions.push(commands.registerCommand('samples.quickInput', async () => {`, - ` const options: { [key: string]: (context: ExtensionContext) => Promise } = {`, - ` showQuickPick,`, - ` showInputBox,`, - ` multiStepInput,`, - ` quickOpen,`, - ` };`, - ` const quickPick = window.createQuickPick();`, - ` quickPick.items = Object.keys(options).map(label => ({ label }));`, - ` quickPick.onDidChangeSelection(selection => {`, - ` if (selection[0]) {`, - ` options[selection[0].label](context)`, - ` .catch(console.error);`, - ` }`, - ` });`, - ` quickPick.onDidHide(() => quickPick.dispose());`, - ` quickPick.show();`, - ` }));`, - `}`, - ].join('\n'); - - it('returns the original file when there is no patch', () => { - assert.strictEqual(getModifiedContentFromDiffHunk(originalContent, ''), originalContent); - }); - - it('returns modified content for patch with multiple additions', () => { - const patch = [ - `@@ -9,6 +9,7 @@ import { window, commands, ExtensionContext } from 'vscode';`, - ` import { showQuickPick, showInputBox } from './basicInput';`, - ` import { multiStepInput } from './multiStepInput';`, - ` import { quickOpen } from './quickOpen';`, - `+import { promptCommand } from './promptCommandWithHistory';`, - ` `, - ` export function activate(context: ExtensionContext) {`, - ` context.subscriptions.push(commands.registerCommand('samples.quickInput', async () => {`, - `@@ -17,6 +18,7 @@ export function activate(context: ExtensionContext) {`, - ` showInputBox,`, - ` multiStepInput,`, - ` quickOpen,`, - `+ promptCommand`, - ` };`, - ` const quickPick = window.createQuickPick();`, - ` quickPick.items = Object.keys(options).map(label => ({ label }));`, - ].join('\n'); - - const lines = originalContent.split('\n'); - lines.splice(11, 0, `import { promptCommand } from './promptCommandWithHistory';`); - lines.splice(20, 0, ` promptCommand`); - - const expectedModifiedContent = lines.join('\n'); - - const modifiedContent = getModifiedContentFromDiffHunk(originalContent, patch); - assert.strictEqual(modifiedContent, expectedModifiedContent); - }); - }); -}); +import { default as assert } from 'assert'; +import { parseDiffHunk, DiffHunk, getModifiedContentFromDiffHunk } from '../../common/diffHunk'; +import { DiffLine, DiffChangeType } from '../../common/diffHunk'; +import { + getDiffLineByPosition, + +} from '../../common/diffPositionMapping'; + +const diff_hunk_0 = [ + `@@ -1,5 +1,6 @@`, + ` {`, + ` "appService.zipIgnorePattern": [`, + ` "node_modules{,/**}"`, + `- ]`, + `-}`, + `\\ No newline at end of file`, + `+ ],`, + `+ "editor.insertSpaces": false`, + `+}`, +].join('\n'); + +describe('diff hunk parsing', () => { + it('diffhunk iterator', () => { + const diffHunkReader = parseDiffHunk(diff_hunk_0); + const diffHunkIter = diffHunkReader.next(); + const diffHunk = diffHunkIter.value; + assert.strictEqual(diffHunk.diffLines.length, 9); + + assert.deepStrictEqual(diffHunk.diffLines[0], new DiffLine(DiffChangeType.Control, -1, -1, 0, `@@ -1,5 +1,6 @@`)); + assert.deepStrictEqual(diffHunk.diffLines[1], new DiffLine(DiffChangeType.Context, 1, 1, 1, ` {`)); + assert.deepStrictEqual( + diffHunk.diffLines[2], + new DiffLine(DiffChangeType.Context, 2, 2, 2, ` "appService.zipIgnorePattern": [`), + ); + assert.deepStrictEqual( + diffHunk.diffLines[3], + new DiffLine(DiffChangeType.Context, 3, 3, 3, ` "node_modules{,/**}"`), + ); + assert.deepStrictEqual(diffHunk.diffLines[4], new DiffLine(DiffChangeType.Delete, 4, -1, 4, `- ]`)); + assert.deepStrictEqual(diffHunk.diffLines[5], new DiffLine(DiffChangeType.Delete, 5, -1, 5, `-}`, false)); + assert.deepStrictEqual(diffHunk.diffLines[6], new DiffLine(DiffChangeType.Add, -1, 4, 7, `+ ],`)); + assert.deepStrictEqual( + diffHunk.diffLines[7], + new DiffLine(DiffChangeType.Add, -1, 5, 8, `+ "editor.insertSpaces": false`), + ); + assert.deepStrictEqual(diffHunk.diffLines[8], new DiffLine(DiffChangeType.Add, -1, 6, 9, `+}`)); + }); + + // #GH-2000 + it('should handle parsing diffs of diff patches', () => { + const diffDiffHunk = [ + '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844', + " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", + ' is detected by configure, but it is not fully functional.', + ' ', + '---- configure.orig 2019-12-21 15:43:09.000000000 -0500', + '-+++ configure 2019-12-21 15:45:31.000000000 -0500', + '-@@ -16791,6 +16791,24 @@', + '+--- configure.orig 2020-07-13 07:11:53.000000000 -0500', + '++++ configure 2020-07-15 10:20:09.000000000 -0500', + '+@@ -16837,6 +16837,24 @@', + ' volatile int val = 1;', + ' int main() {', + " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'", + ].join('\n'); + + const diffHunkReader = parseDiffHunk(diffDiffHunk); + const diffHunkIter = diffHunkReader.next(); + const diffHunk = diffHunkIter.value; + assert.strictEqual(diffHunk.diffLines.length, 13); + assert.strictEqual(diffHunk.newLength, 9); + assert.strictEqual(diffHunk.newLineNumber, 4); + assert.strictEqual(diffHunk.oldLength, 9); + assert.strictEqual(diffHunk.oldLineNumber, 4); + + assert.deepStrictEqual( + diffHunk.diffLines[0], + new DiffLine(DiffChangeType.Control, -1, -1, 0, '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[1], + new DiffLine( + DiffChangeType.Context, + 4, + 4, + 1, + " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", + ), + ); + assert.deepStrictEqual( + diffHunk.diffLines[2], + new DiffLine(DiffChangeType.Context, 5, 5, 2, ' is detected by configure, but it is not fully functional.'), + ); + assert.deepStrictEqual(diffHunk.diffLines[3], new DiffLine(DiffChangeType.Context, 6, 6, 3, ' ')); + assert.deepStrictEqual( + diffHunk.diffLines[4], + new DiffLine(DiffChangeType.Delete, 7, -1, 4, '---- configure.orig\t2019-12-21 15:43:09.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[5], + new DiffLine(DiffChangeType.Delete, 8, -1, 5, '-+++ configure\t2019-12-21 15:45:31.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[6], + new DiffLine(DiffChangeType.Delete, 9, -1, 6, '-@@ -16791,6 +16791,24 @@'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[7], + new DiffLine(DiffChangeType.Add, -1, 7, 7, '+--- configure.orig\t2020-07-13 07:11:53.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[8], + new DiffLine(DiffChangeType.Add, -1, 8, 8, '++++ configure\t2020-07-15 10:20:09.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[9], + new DiffLine(DiffChangeType.Add, -1, 9, 9, '+@@ -16837,6 +16837,24 @@'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[10], + new DiffLine(DiffChangeType.Context, 10, 10, 10, ' volatile int val = 1;'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[11], + new DiffLine(DiffChangeType.Context, 11, 11, 11, ' int main() {'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[12], + new DiffLine(DiffChangeType.Context, 12, 12, 12, " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'"), + ); + }); + + it('getDiffLineByPosition', () => { + const diffHunkReader = parseDiffHunk(diff_hunk_0); + const diffHunkIter = diffHunkReader.next(); + const diffHunk = diffHunkIter.value; + + for (let i = 0; i < diffHunk.diffLines.length; i++) { + const diffLine = diffHunk.diffLines[i]; + assert.deepStrictEqual(getDiffLineByPosition([diffHunk], diffLine.positionInHunk), diffLine, `diff line ${i}`); + } + }); + + it('#239. Diff hunk parsing fails when line count for added content is omitted', () => { + const diffHunkReader = parseDiffHunk('@@ -0,0 +1 @@'); + const diffHunkIter = diffHunkReader.next(); + const diffHunk = diffHunkIter.value; + assert.strictEqual(diffHunk.diffLines.length, 1); + }); + + it('', () => { + const diffHunkReader = parseDiffHunk(`@@ -1 +1,5 @@ +# README ++ ++This is my readme ++ ++Another line"`); + const diffHunkIter = diffHunkReader.next(); + const diffHunk = diffHunkIter.value; + assert.strictEqual(diffHunk.diffLines.length, 5); + }); + + describe('getModifiedContentFromDiffHunk', () => { + const originalContent = [ + `/*---------------------------------------------------------------------------------------------`, + `* Copyright (c) Microsoft Corporation. All rights reserved.`, + `* Licensed under the MIT License. See License.txt in the project root for license information.`, + `*--------------------------------------------------------------------------------------------*/`, + ``, + `'use strict';`, + ``, + `import { window, commands, ExtensionContext } from 'vscode';`, + `import { showQuickPick, showInputBox } from './basicInput';`, + `import { multiStepInput } from './multiStepInput';`, + `import { quickOpen } from './quickOpen';`, + ``, + `export function activate(context: ExtensionContext) {`, + ` context.subscriptions.push(commands.registerCommand('samples.quickInput', async () => {`, + ` const options: { [key: string]: (context: ExtensionContext) => Promise } = {`, + ` showQuickPick,`, + ` showInputBox,`, + ` multiStepInput,`, + ` quickOpen,`, + ` };`, + ` const quickPick = window.createQuickPick();`, + ` quickPick.items = Object.keys(options).map(label => ({ label }));`, + ` quickPick.onDidChangeSelection(selection => {`, + ` if (selection[0]) {`, + ` options[selection[0].label](context)`, + ` .catch(console.error);`, + ` }`, + ` });`, + ` quickPick.onDidHide(() => quickPick.dispose());`, + ` quickPick.show();`, + ` }));`, + `}`, + ].join('\n'); + + it('returns the original file when there is no patch', () => { + assert.strictEqual(getModifiedContentFromDiffHunk(originalContent, ''), originalContent); + }); + + it('returns modified content for patch with multiple additions', () => { + const patch = [ + `@@ -9,6 +9,7 @@ import { window, commands, ExtensionContext } from 'vscode';`, + ` import { showQuickPick, showInputBox } from './basicInput';`, + ` import { multiStepInput } from './multiStepInput';`, + ` import { quickOpen } from './quickOpen';`, + `+import { promptCommand } from './promptCommandWithHistory';`, + ` `, + ` export function activate(context: ExtensionContext) {`, + ` context.subscriptions.push(commands.registerCommand('samples.quickInput', async () => {`, + `@@ -17,6 +18,7 @@ export function activate(context: ExtensionContext) {`, + ` showInputBox,`, + ` multiStepInput,`, + ` quickOpen,`, + `+ promptCommand`, + ` };`, + ` const quickPick = window.createQuickPick();`, + ` quickPick.items = Object.keys(options).map(label => ({ label }));`, + ].join('\n'); + + const lines = originalContent.split('\n'); + lines.splice(11, 0, `import { promptCommand } from './promptCommandWithHistory';`); + lines.splice(20, 0, ` promptCommand`); + + const expectedModifiedContent = lines.join('\n'); + + const modifiedContent = getModifiedContentFromDiffHunk(originalContent, patch); + assert.strictEqual(modifiedContent, expectedModifiedContent); + }); + }); +}); diff --git a/src/test/common/protocol.test.ts b/src/test/common/protocol.test.ts index 8e2d0927f6..fbaeca45b4 100644 --- a/src/test/common/protocol.test.ts +++ b/src/test/common/protocol.test.ts @@ -1,352 +1,353 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import * as ssh from '../../env/node/ssh'; -import { Protocol, ProtocolType } from '../../common/protocol'; - -const SSH_CONFIG_WITH_HOST_ALIASES = ` -Host gh_nocap - User git - Hostname github.com - -Host gh_cap - User git - HostName github.com -`; - -const str = (x: any) => JSON.stringify(x); - -const testRemote = (remote: { - uri: any; - expectedType: ProtocolType; - expectedHost: string; - expectedOwner: string; - expectedRepositoryName: string; -}) => - describe(`new Protocol(${str(remote.uri)})`, () => { - let protocol: Protocol; - before(() => (protocol = new Protocol(remote.uri))); - - it(`type should be ${ProtocolType[remote.expectedType]}`, () => - assert.strictEqual(protocol.type, remote.expectedType)); - it(`host should be ${str(remote.expectedHost)}`, () => assert.strictEqual(protocol.host, remote.expectedHost)); - it(`owner should be ${str(remote.expectedOwner)}`, () => assert.strictEqual(protocol.owner, remote.expectedOwner)); - it(`repositoryName should be ${str(remote.expectedRepositoryName)}`, () => - assert.strictEqual(protocol.repositoryName, remote.expectedRepositoryName)); - }); - -describe('Protocol', () => { - describe('with HTTP and HTTPS remotes', () => - [ - { - uri: 'http://rmacfarlane@github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://rmacfarlane:password@www.github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://github.com/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://www.github.com/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://github.com:/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'http://github.com:433/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://rmacfarlane@github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://github.com/Microsoft/vscode', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://github.com/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://github.com:/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://github.com:433/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://www.github.com:433/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'https://github.enterprise.corp/Microsoft/vscode.git', - expectedType: ProtocolType.HTTP, - expectedHost: 'github.enterprise.corp', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - ].forEach(testRemote)); - - it('should handle SSH remotes', () => - [ - { - uri: 'ssh://git@github.com/Microsoft/vscode', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'ssh://github.com:Microsoft/vscode.git', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'ssh://git@github.com:433/Microsoft/vscode', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'ssh://user@git.server.org:project.git', - expectedType: ProtocolType.SSH, - expectedHost: 'git.server.org', - expectedOwner: '', - expectedRepositoryName: 'project', - }, - ].forEach(testRemote)); - - it('should handle SCP-like remotes', () => - [ - { - uri: 'git@github.com:Microsoft/vscode', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'git@github.com:Microsoft/vscode.git', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'github.com:Microsoft/vscode.git', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'Microsoft', - expectedRepositoryName: 'vscode', - }, - { - uri: 'user@git.server.org:project.git', - expectedType: ProtocolType.SSH, - expectedHost: 'git.server.org', - expectedOwner: '', - expectedRepositoryName: 'project', - }, - { - uri: 'git.server2.org:project.git', - expectedType: ProtocolType.SSH, - expectedHost: 'git.server2.org', - expectedOwner: '', - expectedRepositoryName: 'project', - }, - ].forEach(testRemote)); - - it('should handle local remotes', () => - [ - { - uri: '/opt/git/project.git', - expectedType: ProtocolType.Local, - expectedHost: '', - expectedOwner: '', - expectedRepositoryName: '', - }, - { - uri: 'file:///opt/git/project.git', - expectedType: ProtocolType.Local, - expectedHost: '', - expectedOwner: '', - expectedRepositoryName: '', - }, - ].forEach(testRemote)); - - describe('toString when generating github remotes', () => - [ - { uri: 'ssh://git@github.com/Microsoft/vscode', expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'ssh://github.com:Microsoft/vscode.git', expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'ssh://git@github.com:433/Microsoft/vscode', expected: 'git@github.com:Microsoft/vscode' }, - ].forEach(remote => - it(`should generate "${remote.expected}" from "${remote.uri}`, () => - assert.strictEqual(new Protocol(remote.uri).toString(), remote.expected)), - )); - - describe('Protocol.update when changing protocol type to SSH', () => - [ - { - uri: 'http://rmacfarlane@github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'http://github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'http://github.com/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'http://github.com:/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'http://github.com:433/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://rmacfarlane@github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://github.com/Microsoft/vscode', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://github.com/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://github.com:/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://github.com:433/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.com:Microsoft/vscode', - }, - { - uri: 'https://github.enterprise.corp/Microsoft/vscode.git', - change: { type: ProtocolType.SSH }, - expected: 'git@github.enterprise.corp:Microsoft/vscode', - }, - ].forEach(remote => - it(`should change "${remote.uri}" to "${remote.expected}"`, () => { - const protocol = new Protocol(remote.uri); - protocol.update(remote.change); - assert.strictEqual(protocol.toString(), remote.expected); - }), - )); - - describe('with a ~/.ssh/config', () => { - before(() => (ssh.Resolvers.current = ssh.Resolvers.fromConfig(SSH_CONFIG_WITH_HOST_ALIASES))); - after(() => (ssh.Resolvers.current = ssh.Resolvers.default)); - - testRemote({ - uri: 'gh_cap:queerviolet/vscode', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'queerviolet', - expectedRepositoryName: 'vscode', - }); - - testRemote({ - uri: 'gh_nocap:queerviolet/vscode', - expectedType: ProtocolType.SSH, - expectedHost: 'github.com', - expectedOwner: 'queerviolet', - expectedRepositoryName: 'vscode', - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import * as ssh from '../../env/node/ssh'; +import { Protocol, ProtocolType } from '../../common/protocol'; + +const SSH_CONFIG_WITH_HOST_ALIASES = ` +Host gh_nocap + User git + Hostname github.com + +Host gh_cap + User git + HostName github.com +`; + +const str = (x: any) => JSON.stringify(x); + +const testRemote = (remote: { + uri: any; + expectedType: ProtocolType; + expectedHost: string; + expectedOwner: string; + expectedRepositoryName: string; +}) => + describe(`new Protocol(${str(remote.uri)})`, () => { + let protocol: Protocol; + before(() => (protocol = new Protocol(remote.uri))); + + it(`type should be ${ProtocolType[remote.expectedType]}`, () => + assert.strictEqual(protocol.type, remote.expectedType)); + it(`host should be ${str(remote.expectedHost)}`, () => assert.strictEqual(protocol.host, remote.expectedHost)); + it(`owner should be ${str(remote.expectedOwner)}`, () => assert.strictEqual(protocol.owner, remote.expectedOwner)); + it(`repositoryName should be ${str(remote.expectedRepositoryName)}`, () => + assert.strictEqual(protocol.repositoryName, remote.expectedRepositoryName)); + }); + +describe('Protocol', () => { + describe('with HTTP and HTTPS remotes', () => + [ + { + uri: 'http://rmacfarlane@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://rmacfarlane:password@www.github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://www.github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com:/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://rmacfarlane@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com:/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://www.github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.enterprise.corp/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.enterprise.corp', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + ].forEach(testRemote)); + + it('should handle SSH remotes', () => + [ + { + uri: 'ssh://git@github.com/Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://git@github.com:433/Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://user@git.server.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + ].forEach(testRemote)); + + it('should handle SCP-like remotes', () => + [ + { + uri: 'git@github.com:Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'git@github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'user@git.server.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + { + uri: 'git.server2.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server2.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + ].forEach(testRemote)); + + it('should handle local remotes', () => + [ + { + uri: '/opt/git/project.git', + expectedType: ProtocolType.Local, + expectedHost: '', + expectedOwner: '', + expectedRepositoryName: '', + }, + { + uri: 'file:///opt/git/project.git', + expectedType: ProtocolType.Local, + expectedHost: '', + expectedOwner: '', + expectedRepositoryName: '', + }, + ].forEach(testRemote)); + + describe('toString when generating github remotes', () => + [ + { uri: 'ssh://git@github.com/Microsoft/vscode', expected: 'git@github.com:Microsoft/vscode' }, + { uri: 'ssh://github.com:Microsoft/vscode.git', expected: 'git@github.com:Microsoft/vscode' }, + { uri: 'ssh://git@github.com:433/Microsoft/vscode', expected: 'git@github.com:Microsoft/vscode' }, + ].forEach(remote => + it(`should generate "${remote.expected}" from "${remote.uri}`, () => + assert.strictEqual(new Protocol(remote.uri).toString(), remote.expected)), + )); + + describe('Protocol.update when changing protocol type to SSH', () => + [ + { + uri: 'http://rmacfarlane@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com:/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com:433/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://rmacfarlane@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com:/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com:433/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.enterprise.corp/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.enterprise.corp:Microsoft/vscode', + }, + ].forEach(remote => + it(`should change "${remote.uri}" to "${remote.expected}"`, () => { + const protocol = new Protocol(remote.uri); + protocol.update(remote.change); + assert.strictEqual(protocol.toString(), remote.expected); + }), + )); + + describe('with a ~/.ssh/config', () => { + before(() => (ssh.Resolvers.current = ssh.Resolvers.fromConfig(SSH_CONFIG_WITH_HOST_ALIASES))); + after(() => (ssh.Resolvers.current = ssh.Resolvers.default)); + + testRemote({ + uri: 'gh_cap:queerviolet/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'queerviolet', + expectedRepositoryName: 'vscode', + }); + + testRemote({ + uri: 'gh_nocap:queerviolet/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'queerviolet', + expectedRepositoryName: 'vscode', + }); + }); +}); diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index 10b43aa2dc..862cba2cf1 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -1,54 +1,55 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import * as utils from '../../common/utils'; -import { EventEmitter } from 'vscode'; -import * as timers from 'timers'; - -describe('utils', () => { - class HookError extends Error { - public errors: any[]; - - constructor(message: string, errors: any[]) { - super(message); - this.errors = errors; - } - } - - describe('formatError', () => { - it('should format a normal error', () => { - const error = new Error('No!'); - assert.strictEqual(utils.formatError(error), 'No!'); - }); - - it('should format an error with submessages', () => { - const error = new HookError('Validation Failed', [ - { message: 'user_id can only have one pending review per pull request' }, - ]); - assert.strictEqual(utils.formatError(error), 'user_id can only have one pending review per pull request'); - }); - - it('should not format when error message contains all information', () => { - const error = new HookError('Validation Failed: Some Validation error', []); - assert.strictEqual(utils.formatError(error), 'Validation Failed: Some Validation error'); - }); - - it('should format an error with submessages that are strings', () => { - const error = new HookError('Validation Failed', ['Can not approve your own pull request']); - assert.strictEqual(utils.formatError(error), 'Can not approve your own pull request'); - }); - - it('should format an error with field errors', () => { - const error = new HookError('Validation Failed', [{ field: 'title', value: 'garbage', code: 'custom' }]); - assert.strictEqual(utils.formatError(error), 'Validation Failed: Value "garbage" cannot be set for field title (code: custom)'); - }); - - it('should format an error with custom ', () => { - const error = new HookError('Validation Failed', [{ message: 'Cannot push to this repo', code: 'custom' }]); - assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import * as utils from '../../common/utils'; +import { EventEmitter } from 'vscode'; +import * as timers from 'timers'; + +describe('utils', () => { + class HookError extends Error { + public errors: any[]; + + constructor(message: string, errors: any[]) { + super(message); + this.errors = errors; + } + } + + describe('formatError', () => { + it('should format a normal error', () => { + const error = new Error('No!'); + assert.strictEqual(utils.formatError(error), 'No!'); + }); + + it('should format an error with submessages', () => { + const error = new HookError('Validation Failed', [ + { message: 'user_id can only have one pending review per pull request' }, + ]); + assert.strictEqual(utils.formatError(error), 'user_id can only have one pending review per pull request'); + }); + + it('should not format when error message contains all information', () => { + const error = new HookError('Validation Failed: Some Validation error', []); + assert.strictEqual(utils.formatError(error), 'Validation Failed: Some Validation error'); + }); + + it('should format an error with submessages that are strings', () => { + const error = new HookError('Validation Failed', ['Can not approve your own pull request']); + assert.strictEqual(utils.formatError(error), 'Can not approve your own pull request'); + }); + + it('should format an error with field errors', () => { + const error = new HookError('Validation Failed', [{ field: 'title', value: 'garbage', code: 'custom' }]); + assert.strictEqual(utils.formatError(error), 'Validation Failed: Value "garbage" cannot be set for field title (code: custom)'); + }); + + it('should format an error with custom ', () => { + const error = new HookError('Validation Failed', [{ message: 'Cannot push to this repo', code: 'custom' }]); + assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); + }); + }); +}); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 3038c33295..2aff8dd00e 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,88 +1,89 @@ -import { default as assert } from 'assert'; -import { parseDiffHunk } from '../common/diffHunk'; - -describe('Extension Tests', function () { - describe('parseDiffHunk', () => { - it('should handle empty string', () => { - const diffHunk = parseDiffHunk(''); - const itr = diffHunk.next(); - assert.strictEqual(itr.done, true); - }); - - it('should handle additions', () => { - const patch = [ - `@@ -5,6 +5,9 @@ if (!defined $initial_reply_to && $prompting) {`, - ` }`, - ` `, - ` if (!$smtp_server) {`, - `+ $smtp_server = $repo->config('sendemail.smtpserver');`, - `+}`, - `+if (!$smtp_server) {`, - ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, - ` if (-x $_) {`, - ` $smtp_server = $_;`, - ].join('\n'); - const diffHunk = parseDiffHunk(patch); - - const itr = diffHunk.next(); - assert.notEqual(itr.value, undefined); - assert.strictEqual(itr.value.oldLineNumber, 5); - assert.strictEqual(itr.value.newLineNumber, 5); - assert.strictEqual(itr.value.oldLength, 6); - assert.strictEqual(itr.value.newLength, 9); - assert.strictEqual(itr.value.positionInHunk, 0); - assert.strictEqual(itr.value.diffLines.length, 10); - }); - - it('should handle deletions', () => { - const patch = [ - `@@ -5,9 +5,6 @@ if (!defined $initial_reply_to && $prompting) {`, - ` }`, - ` `, - ` if (!$smtp_server) {`, - `- $smtp_server = $repo->config('sendemail.smtpserver');`, - `-}`, - `-if (!$smtp_server) {`, - ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, - ` if (-x $_) {`, - ` $smtp_server = $_;`, - ].join('\n'); - const diffHunk = parseDiffHunk(patch); - - const itr = diffHunk.next(); - assert.notEqual(itr.value, undefined); - assert.strictEqual(itr.value.oldLineNumber, 5); - assert.strictEqual(itr.value.newLineNumber, 5); - assert.strictEqual(itr.value.oldLength, 9); - assert.strictEqual(itr.value.newLength, 6); - assert.strictEqual(itr.value.positionInHunk, 0); - assert.strictEqual(itr.value.diffLines.length, 10); - }); - - it('should handle replacements', () => { - const patch = [ - `@@ -5,9 +5,7 @@ if (!defined $initial_reply_to && $prompting) {`, - ` }`, - ` `, - ` if (!$smtp_server) {`, - `- $smtp_server = $repo->config('sendemail.smtpserver');`, - `-}`, - `-if (!$smtp_server) {`, - `+if (fpt_server) {`, - ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, - ` if (-x $_) {`, - ` $smtp_server = $_;`, - ].join('\n'); - const diffHunk = parseDiffHunk(patch); - - const itr = diffHunk.next(); - assert.notEqual(itr.value, undefined); - assert.strictEqual(itr.value.oldLineNumber, 5); - assert.strictEqual(itr.value.newLineNumber, 5); - assert.strictEqual(itr.value.oldLength, 9); - assert.strictEqual(itr.value.newLength, 7); - assert.strictEqual(itr.value.positionInHunk, 0); - assert.strictEqual(itr.value.diffLines.length, 11); - }); - }); -}); +import { default as assert } from 'assert'; +import { parseDiffHunk } from '../common/diffHunk'; + +describe('Extension Tests', function () { + describe('parseDiffHunk', () => { + + it('should handle empty string', () => { + const diffHunk = parseDiffHunk(''); + const itr = diffHunk.next(); + assert.strictEqual(itr.done, true); + }); + + it('should handle additions', () => { + const patch = [ + `@@ -5,6 +5,9 @@ if (!defined $initial_reply_to && $prompting) {`, + ` }`, + ` `, + ` if (!$smtp_server) {`, + `+ $smtp_server = $repo->config('sendemail.smtpserver');`, + `+}`, + `+if (!$smtp_server) {`, + ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, + ` if (-x $_) {`, + ` $smtp_server = $_;`, + ].join('\n'); + const diffHunk = parseDiffHunk(patch); + + const itr = diffHunk.next(); + assert.notEqual(itr.value, undefined); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 6); + assert.strictEqual(itr.value.newLength, 9); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 10); + }); + + it('should handle deletions', () => { + const patch = [ + `@@ -5,9 +5,6 @@ if (!defined $initial_reply_to && $prompting) {`, + ` }`, + ` `, + ` if (!$smtp_server) {`, + `- $smtp_server = $repo->config('sendemail.smtpserver');`, + `-}`, + `-if (!$smtp_server) {`, + ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, + ` if (-x $_) {`, + ` $smtp_server = $_;`, + ].join('\n'); + const diffHunk = parseDiffHunk(patch); + + const itr = diffHunk.next(); + assert.notEqual(itr.value, undefined); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 9); + assert.strictEqual(itr.value.newLength, 6); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 10); + }); + + it('should handle replacements', () => { + const patch = [ + `@@ -5,9 +5,7 @@ if (!defined $initial_reply_to && $prompting) {`, + ` }`, + ` `, + ` if (!$smtp_server) {`, + `- $smtp_server = $repo->config('sendemail.smtpserver');`, + `-}`, + `-if (!$smtp_server) {`, + `+if (fpt_server) {`, + ` foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) {`, + ` if (-x $_) {`, + ` $smtp_server = $_;`, + ].join('\n'); + const diffHunk = parseDiffHunk(patch); + + const itr = diffHunk.next(); + assert.notEqual(itr.value, undefined); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 9); + assert.strictEqual(itr.value.newLength, 7); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 11); + }); + }); +}); diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 4fce6c2d98..814af5fa05 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -1,91 +1,92 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { createSandbox, SinonSandbox } from 'sinon'; - -import { FolderRepositoryManager, titleAndBodyFrom } from '../../github/folderRepositoryManager'; -import { MockRepository } from '../mocks/mockRepository'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { Protocol } from '../../common/protocol'; -import { GitHubRepository } from '../../github/githubRepository'; -import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { GitApiImpl } from '../../api/api1'; -import { CredentialStore } from '../../github/credentials'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { Uri } from 'vscode'; -import { GitHubServerType } from '../../common/authentication'; - -describe('PullRequestManager', function () { - let sinon: SinonSandbox; - let manager: FolderRepositoryManager; - let telemetry: MockTelemetry; - - beforeEach(function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - - telemetry = new MockTelemetry(); - const repository = new MockRepository(); - const context = new MockExtensionContext(); - const credentialStore = new CredentialStore(telemetry, context); - manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('activePullRequest', function () { - it('gets and sets the active pull request', function () { - assert.strictEqual(manager.activePullRequest, undefined); - - const changeFired = sinon.spy(); - manager.onDidChangeActivePullRequest(changeFired); - - const url = 'https://github.com/aaa/bbb.git'; - const protocol = new Protocol(url); - const remote = new GitHubRemote('origin', url, protocol, GitHubServerType.GitHubDotCom); - const rootUri = Uri.file('C:\\users\\test\\repo'); - const repository = new GitHubRepository(remote, rootUri, manager.credentialStore, telemetry); - const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().build(), repository); - const pr = new PullRequestModel(manager.credentialStore, telemetry, repository, remote, prItem); - - manager.activePullRequest = pr; - assert(changeFired.called); - assert.deepStrictEqual(manager.activePullRequest, pr); - }); - }); -}); - -describe('titleAndBodyFrom', function () { - it('separates title and body', async function () { - const message = Promise.resolve('title\n\ndescription 1\n\ndescription 2\n'); - - const result = await titleAndBodyFrom(message); - assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, 'description 1\n\ndescription 2'); - }); - - it('returns only title with no body', async function () { - const message = Promise.resolve('title'); - - const result = await titleAndBodyFrom(message); - assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, ''); - }); - - it('returns only title when body contains only whitespace', async function () { - const message = Promise.resolve('title\n\n'); - - const result = await titleAndBodyFrom(message); - assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, ''); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { createSandbox, SinonSandbox } from 'sinon'; + +import { FolderRepositoryManager, titleAndBodyFrom } from '../../github/folderRepositoryManager'; +import { MockRepository } from '../mocks/mockRepository'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { Protocol } from '../../common/protocol'; +import { GitHubRepository } from '../../github/githubRepository'; +import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; +import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; +import { GitApiImpl } from '../../api/api1'; +import { CredentialStore } from '../../github/credentials'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { Uri } from 'vscode'; +import { GitHubServerType } from '../../common/authentication'; + +describe('PullRequestManager', function () { + let sinon: SinonSandbox; + let manager: FolderRepositoryManager; + let telemetry: MockTelemetry; + + beforeEach(function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + + telemetry = new MockTelemetry(); + const repository = new MockRepository(); + const context = new MockExtensionContext(); + const credentialStore = new CredentialStore(telemetry, context); + manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('activePullRequest', function () { + it('gets and sets the active pull request', function () { + assert.strictEqual(manager.activePullRequest, undefined); + + const changeFired = sinon.spy(); + manager.onDidChangeActivePullRequest(changeFired); + + const url = 'https://github.com/aaa/bbb.git'; + const protocol = new Protocol(url); + const remote = new GitHubRemote('origin', url, protocol, GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const repository = new GitHubRepository(remote, rootUri, manager.credentialStore, telemetry); + const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().build(), repository); + const pr = new PullRequestModel(manager.credentialStore, telemetry, repository, remote, prItem); + + manager.activePullRequest = pr; + assert(changeFired.called); + assert.deepStrictEqual(manager.activePullRequest, pr); + }); + }); +}); + +describe('titleAndBodyFrom', function () { + it('separates title and body', async function () { + const message = Promise.resolve('title\n\ndescription 1\n\ndescription 2\n'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'description 1\n\ndescription 2'); + }); + + it('returns only title with no body', async function () { + const message = Promise.resolve('title'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); + }); + + it('returns only title when body contains only whitespace', async function () { + const message = Promise.resolve('title\n\n'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); + }); +}); diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 493f4a99f3..d3fefd0c33 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -1,55 +1,56 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { SinonSandbox, createSandbox } from 'sinon'; -import { CredentialStore } from '../../github/credentials'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { Protocol } from '../../common/protocol'; -import { GitHubRepository } from '../../github/githubRepository'; -import { Uri } from 'vscode'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { GitHubManager } from '../../authentication/githubServer'; -import { GitHubServerType } from '../../common/authentication'; - -describe('GitHubRepository', function () { - let sinon: SinonSandbox; - let credentialStore: CredentialStore; - let telemetry: MockTelemetry; - let context: MockExtensionContext; - - beforeEach(function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - - telemetry = new MockTelemetry(); - context = new MockExtensionContext(); - credentialStore = new CredentialStore(telemetry, context); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('isGitHubDotCom', function () { - it('detects when the remote is pointing to github.com', function () { - const url = 'https://github.com/some/repo'; - const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); - const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); - assert(GitHubManager.isGithubDotCom(Uri.parse(remote.url).authority)); - }); - - it('detects when the remote is pointing somewhere other than github.com', function () { - const url = 'https://github.enterprise.horse/some/repo'; - const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); - const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); - // assert(! dotcomRepository.isGitHubDotCom); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { SinonSandbox, createSandbox } from 'sinon'; +import { CredentialStore } from '../../github/credentials'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { Protocol } from '../../common/protocol'; +import { GitHubRepository } from '../../github/githubRepository'; +import { Uri } from 'vscode'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubManager } from '../../authentication/githubServer'; +import { GitHubServerType } from '../../common/authentication'; + +describe('GitHubRepository', function () { + let sinon: SinonSandbox; + let credentialStore: CredentialStore; + let telemetry: MockTelemetry; + let context: MockExtensionContext; + + beforeEach(function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + + telemetry = new MockTelemetry(); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('isGitHubDotCom', function () { + it('detects when the remote is pointing to github.com', function () { + const url = 'https://github.com/some/repo'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + assert(GitHubManager.isGithubDotCom(Uri.parse(remote.url).authority)); + }); + + it('detects when the remote is pointing somewhere other than github.com', function () { + const url = 'https://github.enterprise.horse/some/repo'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + // assert(! dotcomRepository.isGitHubDotCom); + }); + }); +}); diff --git a/src/test/github/pullRequestGitHelper.test.ts b/src/test/github/pullRequestGitHelper.test.ts index 8779e56c9e..3a6f799006 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -1,99 +1,100 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; - -import { MockRepository } from '../mocks/mockRepository'; -import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { Protocol } from '../../common/protocol'; -import { CredentialStore } from '../../github/credentials'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { SinonSandbox, createSandbox } from 'sinon'; -import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { RefType } from '../../api/api1'; -import { RepositoryBuilder } from '../builders/rest/repoBuilder'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { GitHubServerType } from '../../common/authentication'; - -describe('PullRequestGitHelper', function () { - let sinon: SinonSandbox; - let repository: MockRepository; - let telemetry: MockTelemetry; - let credentialStore: CredentialStore; - let context: MockExtensionContext; - - beforeEach(function () { - sinon = createSandbox(); - - MockCommandRegistry.install(sinon); - - repository = new MockRepository(); - telemetry = new MockTelemetry(); - context = new MockExtensionContext(); - credentialStore = new CredentialStore(telemetry, context); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('checkoutFromFork', function () { - it('fetches, checks out, and configures a branch from a fork', async function () { - const url = 'git@github.com:owner/name.git'; - const remote = new GitHubRemote('elsewhere', url, new Protocol(url), GitHubServerType.GitHubDotCom); - const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); - - const prItem = convertRESTPullRequestToRawPullRequest( - new PullRequestBuilder() - .number(100) - .user(u => u.login('me')) - .base(b => { - (b.repo)(r => (r).clone_url('git@github.com:owner/name.git')); - }) - .head(h => { - h.repo(r => (r).clone_url('git@github.com:you/name.git')); - h.ref('my-branch'); - }) - .build(), - gitHubRepository, - ); - - repository.expectFetch('you', 'my-branch:pr/me/100', 1); - repository.expectPull(true); - - const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); - - if (!pullRequest.isResolved()) { - assert(false, 'pull request head not resolved successfully'); - } - - await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined, { report: () => undefined }); - - assert.deepStrictEqual(repository.state.remotes, [ - { - name: 'you', - fetchUrl: 'git@github.com:you/name', - pushUrl: 'git@github.com:you/name', - isReadOnly: false, - }, - ]); - assert.deepStrictEqual(repository.state.HEAD, { - type: RefType.Head, - name: 'pr/me/100', - commit: undefined, - upstream: { - remote: 'you', - name: 'my-branch', - }, - }); - assert.strictEqual(await repository.getConfig('branch.pr/me/100.github-pr-owner-number'), 'owner#name#100'); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; + +import { MockRepository } from '../mocks/mockRepository'; +import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { Protocol } from '../../common/protocol'; +import { CredentialStore } from '../../github/credentials'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { SinonSandbox, createSandbox } from 'sinon'; +import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; +import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; +import { RefType } from '../../api/api1'; +import { RepositoryBuilder } from '../builders/rest/repoBuilder'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubServerType } from '../../common/authentication'; + +describe('PullRequestGitHelper', function () { + let sinon: SinonSandbox; + let repository: MockRepository; + let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + let context: MockExtensionContext; + + beforeEach(function () { + sinon = createSandbox(); + + MockCommandRegistry.install(sinon); + + repository = new MockRepository(); + telemetry = new MockTelemetry(); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('checkoutFromFork', function () { + it('fetches, checks out, and configures a branch from a fork', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('elsewhere', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(100) + .user(u => u.login('me')) + .base(b => { + (b.repo)(r => (r).clone_url('git@github.com:owner/name.git')); + }) + .head(h => { + h.repo(r => (r).clone_url('git@github.com:you/name.git')); + h.ref('my-branch'); + }) + .build(), + gitHubRepository, + ); + + repository.expectFetch('you', 'my-branch:pr/me/100', 1); + repository.expectPull(true); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + if (!pullRequest.isResolved()) { + assert(false, 'pull request head not resolved successfully'); + } + + await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined, { report: () => undefined }); + + assert.deepStrictEqual(repository.state.remotes, [ + { + name: 'you', + fetchUrl: 'git@github.com:you/name', + pushUrl: 'git@github.com:you/name', + isReadOnly: false, + }, + ]); + assert.deepStrictEqual(repository.state.HEAD, { + type: RefType.Head, + name: 'pr/me/100', + commit: undefined, + upstream: { + remote: 'you', + name: 'my-branch', + }, + }); + assert.strictEqual(await repository.getConfig('branch.pr/me/100.github-pr-owner-number'), 'owner#name#100'); + }); + }); +}); diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index 7a976af689..298945dc4d 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -1,153 +1,154 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { CredentialStore } from '../../github/credentials'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { GithubItemStateEnum } from '../../github/interface'; -import { Protocol } from '../../common/protocol'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { SinonSandbox, createSandbox } from 'sinon'; -import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { NetworkStatus } from 'apollo-client'; -import { Resource } from '../../common/resources'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { GitHubServerType } from '../../common/authentication'; -import { mergeQuerySchemaWithShared } from '../../github/common'; -const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; - -const telemetry = new MockTelemetry(); -const protocol = new Protocol('https://github.com/github/test.git'); -const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); - -const reviewThreadResponse = { - id: '1', - isResolved: false, - viewerCanResolve: true, - path: 'README.md', - diffSide: 'RIGHT', - startLine: null, - line: 4, - originalStartLine: null, - originalLine: 4, - isOutdated: false, - comments: { - nodes: [ - { - id: 1, - body: "the world's largest frog weighs up to 7.2 lbs", - graphNodeId: '1', - diffHunk: '', - commit: { - oid: '' - }, - reactionGroups: [] - }, - ], - }, -}; - -describe('PullRequestModel', function () { - let sinon: SinonSandbox; - let credentials: CredentialStore; - let repo: MockGitHubRepository; - let context: MockExtensionContext; - - beforeEach(function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - - context = new MockExtensionContext(); - credentials = new CredentialStore(telemetry, context); - repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); - Resource.initialize(context); - }); - - afterEach(function () { - repo.dispose(); - context.dispose(); - credentials.dispose(); - sinon.restore(); - }); - - it('should return `state` properly as `open`', function () { - const pr = new PullRequestBuilder().state('open').build(); - const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); - - assert.strictEqual(open.state, GithubItemStateEnum.Open); - }); - - it('should return `state` properly as `closed`', function () { - const pr = new PullRequestBuilder().state('closed').build(); - const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); - - assert.strictEqual(open.state, GithubItemStateEnum.Closed); - }); - - it('should return `state` properly as `merged`', function () { - const pr = new PullRequestBuilder().merged(true).state('closed').build(); - const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); - - assert.strictEqual(open.state, GithubItemStateEnum.Merged); - }); - - describe('reviewThreadCache', function () { - it('should update the cache when then cache is initialized', async function () { - const pr = new PullRequestBuilder().build(); - const model = new PullRequestModel( - credentials, - telemetry, - repo, - remote, - convertRESTPullRequestToRawPullRequest(pr, repo), - ); - - repo.queryProvider.expectGraphQLQuery( - { - query: queries.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: pr.number, - }, - }, - { - data: { - repository: { - pullRequest: { - reviewThreads: { - nodes: [ - reviewThreadResponse - ], - pageInfo: { - hasNextPage: false - } - }, - }, - }, - }, - loading: false, - stale: false, - networkStatus: NetworkStatus.ready, - }, - ); - - const onDidChangeReviewThreads = sinon.spy(); - model.onDidChangeReviewThreads(onDidChangeReviewThreads); - - await model.initializeReviewThreadCache(); - - assert.strictEqual(Object.keys(model.reviewThreadsCache).length, 1); - assert(onDidChangeReviewThreads.calledOnce); - assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['added'].length, 1); - assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['changed'].length, 0); - assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['removed'].length, 0); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { CredentialStore } from '../../github/credentials'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { GithubItemStateEnum } from '../../github/interface'; +import { Protocol } from '../../common/protocol'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; +import { SinonSandbox, createSandbox } from 'sinon'; +import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { NetworkStatus } from 'apollo-client'; +import { Resource } from '../../common/resources'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubServerType } from '../../common/authentication'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; + +const telemetry = new MockTelemetry(); +const protocol = new Protocol('https://github.com/github/test.git'); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); + +const reviewThreadResponse = { + id: '1', + isResolved: false, + viewerCanResolve: true, + path: 'README.md', + diffSide: 'RIGHT', + startLine: null, + line: 4, + originalStartLine: null, + originalLine: 4, + isOutdated: false, + comments: { + nodes: [ + { + id: 1, + body: "the world's largest frog weighs up to 7.2 lbs", + graphNodeId: '1', + diffHunk: '', + commit: { + oid: '' + }, + reactionGroups: [] + }, + ], + }, +}; + +describe('PullRequestModel', function () { + let sinon: SinonSandbox; + let credentials: CredentialStore; + let repo: MockGitHubRepository; + let context: MockExtensionContext; + + beforeEach(function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + + context = new MockExtensionContext(); + credentials = new CredentialStore(telemetry, context); + repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); + Resource.initialize(context); + }); + + afterEach(function () { + repo.dispose(); + context.dispose(); + credentials.dispose(); + sinon.restore(); + }); + + it('should return `state` properly as `open`', function () { + const pr = new PullRequestBuilder().state('open').build(); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + + assert.strictEqual(open.state, GithubItemStateEnum.Open); + }); + + it('should return `state` properly as `closed`', function () { + const pr = new PullRequestBuilder().state('closed').build(); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + + assert.strictEqual(open.state, GithubItemStateEnum.Closed); + }); + + it('should return `state` properly as `merged`', function () { + const pr = new PullRequestBuilder().merged(true).state('closed').build(); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + + assert.strictEqual(open.state, GithubItemStateEnum.Merged); + }); + + describe('reviewThreadCache', function () { + it('should update the cache when then cache is initialized', async function () { + const pr = new PullRequestBuilder().build(); + const model = new PullRequestModel( + credentials, + telemetry, + repo, + remote, + convertRESTPullRequestToRawPullRequest(pr, repo), + ); + + repo.queryProvider.expectGraphQLQuery( + { + query: queries.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: pr.number, + }, + }, + { + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + reviewThreadResponse + ], + pageInfo: { + hasNextPage: false + } + }, + }, + }, + }, + loading: false, + stale: false, + networkStatus: NetworkStatus.ready, + }, + ); + + const onDidChangeReviewThreads = sinon.spy(); + model.onDidChangeReviewThreads(onDidChangeReviewThreads); + + await model.initializeReviewThreadCache(); + + assert.strictEqual(Object.keys(model.reviewThreadsCache).length, 1); + assert(onDidChangeReviewThreads.calledOnce); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['added'].length, 1); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['changed'].length, 0); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['removed'].length, 0); + }); + }); +}); diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index bf80c1edae..cbb3a710d4 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -1,135 +1,136 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import * as vscode from 'vscode'; -import { SinonSandbox, createSandbox, match as sinonMatch } from 'sinon'; - -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { MockRepository } from '../mocks/mockRepository'; -import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { Protocol } from '../../common/protocol'; -import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { GitApiImpl } from '../../api/api1'; -import { CredentialStore } from '../../github/credentials'; -import { GitHubServerType } from '../../common/authentication'; -import { GitHubRemote } from '../../common/remote'; -import { CheckState } from '../../github/interface'; - -const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); - -describe('PullRequestOverview', function () { - let sinon: SinonSandbox; - let pullRequestManager: FolderRepositoryManager; - let context: MockExtensionContext; - let remote: GitHubRemote; - let repo: MockGitHubRepository; - let telemetry: MockTelemetry; - let credentialStore: CredentialStore; - - beforeEach(async function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - context = new MockExtensionContext(); - - const repository = new MockRepository(); - telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry, context); - pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); - - const url = 'https://github.com/aaa/bbb'; - remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); - repo = new MockGitHubRepository(remote, pullRequestManager.credentialStore, telemetry, sinon); - }); - - afterEach(function () { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.currentPanel.dispose(); - } - - pullRequestManager.dispose(); - context.dispose(); - sinon.restore(); - }); - - describe('createOrShow', function () { - it('creates a new panel', async function () { - assert.strictEqual(PullRequestOverviewPanel.currentPanel, undefined); - const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); - - repo.addGraphQLPullRequest(builder => { - builder.pullRequest(response => { - response.repository(r => { - r.pullRequest(pr => pr.number(1000)); - }); - }); - }); - - const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); - - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel); - - assert( - createWebviewPanel.calledWith(sinonMatch.string, 'Pull Request #1000', vscode.ViewColumn.One, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.joinPath(EXTENSION_URI, 'dist')], - }), - ); - assert.notStrictEqual(PullRequestOverviewPanel.currentPanel, undefined); - }); - - it('reveals and updates an existing panel', async function () { - const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); - - repo.addGraphQLPullRequest(builder => { - builder.pullRequest(response => { - response.repository(r => { - r.pullRequest(pr => pr.number(1000)); - }); - }); - }); - repo.addGraphQLPullRequest(builder => { - builder.pullRequest(response => { - response.repository(r => { - r.pullRequest(pr => pr.number(2000)); - }); - }); - }); - - const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel0 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem0); - const resolveStub = sinon.stub(pullRequestManager, 'resolvePullRequest').resolves(prModel0); - sinon.stub(prModel0, 'getReviewRequests').resolves([]); - sinon.stub(prModel0, 'getTimelineEvents').resolves([]); - sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel0); - - const panel0 = PullRequestOverviewPanel.currentPanel; - assert.notStrictEqual(panel0, undefined); - assert.strictEqual(createWebviewPanel.callCount, 1); - assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #1000'); - - const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); - const prModel1 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem1); - resolveStub.resolves(prModel1); - sinon.stub(prModel1, 'getReviewRequests').resolves([]); - sinon.stub(prModel1, 'getTimelineEvents').resolves([]); - sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel1); - - assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); - assert.strictEqual(createWebviewPanel.callCount, 1); - assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #2000'); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import * as vscode from 'vscode'; +import { SinonSandbox, createSandbox, match as sinonMatch } from 'sinon'; + +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockRepository } from '../mocks/mockRepository'; +import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { Protocol } from '../../common/protocol'; +import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; +import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { GitApiImpl } from '../../api/api1'; +import { CredentialStore } from '../../github/credentials'; +import { GitHubServerType } from '../../common/authentication'; +import { GitHubRemote } from '../../common/remote'; +import { CheckState } from '../../github/interface'; + +const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); + +describe('PullRequestOverview', function () { + let sinon: SinonSandbox; + let pullRequestManager: FolderRepositoryManager; + let context: MockExtensionContext; + let remote: GitHubRemote; + let repo: MockGitHubRepository; + let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + + beforeEach(async function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + context = new MockExtensionContext(); + + const repository = new MockRepository(); + telemetry = new MockTelemetry(); + credentialStore = new CredentialStore(telemetry, context); + pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + + const url = 'https://github.com/aaa/bbb'; + remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + repo = new MockGitHubRepository(remote, pullRequestManager.credentialStore, telemetry, sinon); + }); + + afterEach(function () { + if (PullRequestOverviewPanel.currentPanel) { + PullRequestOverviewPanel.currentPanel.dispose(); + } + + pullRequestManager.dispose(); + context.dispose(); + sinon.restore(); + }); + + describe('createOrShow', function () { + it('creates a new panel', async function () { + assert.strictEqual(PullRequestOverviewPanel.currentPanel, undefined); + const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); + + repo.addGraphQLPullRequest(builder => { + builder.pullRequest(response => { + response.repository(r => { + r.pullRequest(pr => pr.number(1000)); + }); + }); + }); + + const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); + + await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel); + + assert( + createWebviewPanel.calledWith(sinonMatch.string, 'Pull Request #1000', vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(EXTENSION_URI, 'dist')], + }), + ); + assert.notStrictEqual(PullRequestOverviewPanel.currentPanel, undefined); + }); + + it('reveals and updates an existing panel', async function () { + const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); + + repo.addGraphQLPullRequest(builder => { + builder.pullRequest(response => { + response.repository(r => { + r.pullRequest(pr => pr.number(1000)); + }); + }); + }); + repo.addGraphQLPullRequest(builder => { + builder.pullRequest(response => { + response.repository(r => { + r.pullRequest(pr => pr.number(2000)); + }); + }); + }); + + const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + const prModel0 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem0); + const resolveStub = sinon.stub(pullRequestManager, 'resolvePullRequest').resolves(prModel0); + sinon.stub(prModel0, 'getReviewRequests').resolves([]); + sinon.stub(prModel0, 'getTimelineEvents').resolves([]); + sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); + await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel0); + + const panel0 = PullRequestOverviewPanel.currentPanel; + assert.notStrictEqual(panel0, undefined); + assert.strictEqual(createWebviewPanel.callCount, 1); + assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #1000'); + + const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); + const prModel1 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem1); + resolveStub.resolves(prModel1); + sinon.stub(prModel1, 'getReviewRequests').resolves([]); + sinon.stub(prModel1, 'getTimelineEvents').resolves([]); + sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); + await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel1); + + assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); + assert.strictEqual(createWebviewPanel.callCount, 1); + assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #2000'); + }); + }); +}); diff --git a/src/test/github/utils.test.ts b/src/test/github/utils.test.ts index 71c93c0851..2b7726e5c0 100644 --- a/src/test/github/utils.test.ts +++ b/src/test/github/utils.test.ts @@ -1,45 +1,46 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { getPRFetchQuery, sanitizeIssueTitle } from '../../github/utils'; - -describe('utils', () => { - - describe('getPRFetchQuery', () => { - it('replaces all instances of ${user}', () => { - const repo = 'microsoft/vscode-pull-request-github'; - const user = 'rmacfarlane'; - const query = 'reviewed-by:${user} -author:${user}'; - const result = getPRFetchQuery(repo, user, query) - assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr repo:microsoft/vscode-pull-request-github'); - }); - }); - - describe('sanitizeIssueTitle', () => { - [ - { input: 'Issue', expected: 'Issue' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue @ A', expected: 'Issue-A' }, - { input: "Issue 'A'", expected: 'Issue-A' }, - { input: 'Issue "A"', expected: 'Issue-A' }, - { input: '@Issue "A"', expected: 'Issue-A' }, - { input: 'Issue "A"%', expected: 'Issue-A' }, - { input: 'Issue .A', expected: 'Issue-A' }, - { input: 'Issue ,A', expected: 'Issue-A' }, - { input: 'Issue :A', expected: 'Issue-A' }, - { input: 'Issue ;A', expected: 'Issue-A' }, - { input: 'Issue ~A', expected: 'Issue-A' }, - { input: 'Issue #A', expected: 'Issue-A' }, - ].forEach(testCase => { - it(`Transforms '${testCase.input}' into '${testCase.expected}'`, () => { - const actual = sanitizeIssueTitle(testCase.input); - assert.strictEqual(actual, testCase.expected); - }); - }); - }); -}); \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { getPRFetchQuery, sanitizeIssueTitle } from '../../github/utils'; + +describe('utils', () => { + + describe('getPRFetchQuery', () => { + it('replaces all instances of ${user}', () => { + const repo = 'microsoft/vscode-pull-request-github'; + const user = 'rmacfarlane'; + const query = 'reviewed-by:${user} -author:${user}'; + const result = getPRFetchQuery(repo, user, query) + assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr repo:microsoft/vscode-pull-request-github'); + }); + }); + + describe('sanitizeIssueTitle', () => { + [ + { input: 'Issue', expected: 'Issue' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue @ A', expected: 'Issue-A' }, + { input: "Issue 'A'", expected: 'Issue-A' }, + { input: 'Issue "A"', expected: 'Issue-A' }, + { input: '@Issue "A"', expected: 'Issue-A' }, + { input: 'Issue "A"%', expected: 'Issue-A' }, + { input: 'Issue .A', expected: 'Issue-A' }, + { input: 'Issue ,A', expected: 'Issue-A' }, + { input: 'Issue :A', expected: 'Issue-A' }, + { input: 'Issue ;A', expected: 'Issue-A' }, + { input: 'Issue ~A', expected: 'Issue-A' }, + { input: 'Issue #A', expected: 'Issue-A' }, + ].forEach(testCase => { + it(`Transforms '${testCase.input}' into '${testCase.expected}'`, () => { + const actual = sanitizeIssueTitle(testCase.input); + assert.strictEqual(actual, testCase.expected); + }); + }); + }); +}); diff --git a/src/test/globalHooks.ts b/src/test/globalHooks.ts index 9ebdedf6ab..0037a52c5c 100644 --- a/src/test/globalHooks.ts +++ b/src/test/globalHooks.ts @@ -1,30 +1,31 @@ -// Global Mocha test hooks. - -import * as util from 'util'; - -const original = { - log: console.log, - error: console.error, -}; - -beforeEach(function () { - const currentTest = this.currentTest as { - consoleOutputs?: string[]; - consoleErrors?: string[]; - }; - console.log = function captureLog() { - original.log.apply(console, arguments); - const formatted = util.format.apply(util, arguments); - currentTest.consoleOutputs = (currentTest.consoleOutputs || []).concat(formatted); - }; - console.error = function captureError() { - original.error.apply(console, arguments); - const formatted = util.format.apply(util, arguments); - currentTest.consoleErrors = (currentTest.consoleErrors || []).concat(formatted); - }; -}); - -afterEach(function () { - console.log = original.log; - console.error = original.error; -}); +// Global Mocha test hooks. + +import * as util from 'util'; + +const original = { + + log: console.log, + error: console.error, +}; + +beforeEach(function () { + const currentTest = this.currentTest as { + consoleOutputs?: string[]; + consoleErrors?: string[]; + }; + console.log = function captureLog() { + original.log.apply(console, arguments); + const formatted = util.format.apply(util, arguments); + currentTest.consoleOutputs = (currentTest.consoleOutputs || []).concat(formatted); + }; + console.error = function captureError() { + original.error.apply(console, arguments); + const formatted = util.format.apply(util, arguments); + currentTest.consoleErrors = (currentTest.consoleErrors || []).concat(formatted); + }; +}); + +afterEach(function () { + console.log = original.log; + console.error = original.error; +}); diff --git a/src/test/index.ts b/src/test/index.ts index 677bc56144..21f11aeb7a 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,57 +1,58 @@ -// This file is providing the test runner to use when running extension tests. -import * as path from 'path'; -import * as vscode from 'vscode'; -import glob from 'glob'; -import Mocha from 'mocha'; -import { mockWebviewEnvironment } from './mocks/mockWebviewEnvironment'; -import { EXTENSION_ID } from '../constants'; - -function addTests(mocha: Mocha, root: string): Promise { - return new Promise((resolve, reject) => { - glob('**/**.test.js', { cwd: root }, (error, files) => { - if (error) { - return reject(error); - } - - for (const testFile of files) { - mocha.addFile(path.join(root, testFile)); - } - resolve(); - }); - }); -} - -async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise { - // Ensure the dev-mode extension is activated - await vscode.extensions.getExtension(EXTENSION_ID)!.activate(); - - mockWebviewEnvironment.install(global); - - const mocha = new Mocha({ - ui: 'bdd', - color: true - }); - mocha.addFile(path.resolve(testsRoot, 'globalHooks.js')); - - await addTests(mocha, testsRoot); - await addTests(mocha, path.resolve(testsRoot, '../../../webviews/')); - - if (process.env.TEST_JUNIT_XML_PATH) { - mocha.reporter('mocha-multi-reporters', { - reporterEnabled: 'mocha-junit-reporter, spec', - mochaJunitReporterReporterOptions: { - mochaFile: process.env.TEST_JUNIT_XML_PATH, - suiteTitleSeparatedBy: ' / ', - outputs: true, - }, - }); - } - - return mocha.run(failures => clb(null, failures)); -} - -export function run(testsRoot: string, clb: (error: Error | null, failures?: number) => void): void { - require('source-map-support').install(); - - runAllExtensionTests(testsRoot, clb); -} +// This file is providing the test runner to use when running extension tests. +import * as path from 'path'; +import * as vscode from 'vscode'; +import glob from 'glob'; +import Mocha from 'mocha'; + +import { mockWebviewEnvironment } from './mocks/mockWebviewEnvironment'; +import { EXTENSION_ID } from '../constants'; + +function addTests(mocha: Mocha, root: string): Promise { + return new Promise((resolve, reject) => { + glob('**/**.test.js', { cwd: root }, (error, files) => { + if (error) { + return reject(error); + } + + for (const testFile of files) { + mocha.addFile(path.join(root, testFile)); + } + resolve(); + }); + }); +} + +async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise { + // Ensure the dev-mode extension is activated + await vscode.extensions.getExtension(EXTENSION_ID)!.activate(); + + mockWebviewEnvironment.install(global); + + const mocha = new Mocha({ + ui: 'bdd', + color: true + }); + mocha.addFile(path.resolve(testsRoot, 'globalHooks.js')); + + await addTests(mocha, testsRoot); + await addTests(mocha, path.resolve(testsRoot, '../../../webviews/')); + + if (process.env.TEST_JUNIT_XML_PATH) { + mocha.reporter('mocha-multi-reporters', { + reporterEnabled: 'mocha-junit-reporter, spec', + mochaJunitReporterReporterOptions: { + mochaFile: process.env.TEST_JUNIT_XML_PATH, + suiteTitleSeparatedBy: ' / ', + outputs: true, + }, + }); + } + + return mocha.run(failures => clb(null, failures)); +} + +export function run(testsRoot: string, clb: (error: Error | null, failures?: number) => void): void { + require('source-map-support').install(); + + runAllExtensionTests(testsRoot, clb); +} diff --git a/src/test/issues/issuesUtils.test.ts b/src/test/issues/issuesUtils.test.ts index 90c45e0fa0..cb5c978a31 100644 --- a/src/test/issues/issuesUtils.test.ts +++ b/src/test/issues/issuesUtils.test.ts @@ -1,52 +1,53 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { parseIssueExpressionOutput, ISSUE_OR_URL_EXPRESSION } from '../../github/utils'; - -describe('Issues utilities', function () { - it('regular expressions', async function () { - const issueNumber = '#1234'; - const issueNumberParsed = parseIssueExpressionOutput(issueNumber.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(issueNumberParsed?.issueNumber, 1234); - assert.strictEqual(issueNumberParsed?.commentNumber, undefined); - assert.strictEqual(issueNumberParsed?.name, undefined); - assert.strictEqual(issueNumberParsed?.owner, undefined); - - const issueNumberGH = 'GH-321'; - const issueNumberGHParsed = parseIssueExpressionOutput(issueNumberGH.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(issueNumberGHParsed?.issueNumber, 321); - assert.strictEqual(issueNumberGHParsed?.commentNumber, undefined); - assert.strictEqual(issueNumberGHParsed?.name, undefined); - assert.strictEqual(issueNumberGHParsed?.owner, undefined); - const issueSingleDigit = '#1'; - const issueSingleDigitParsed = parseIssueExpressionOutput(issueSingleDigit.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(issueSingleDigitParsed?.issueNumber, 1); - assert.strictEqual(issueSingleDigitParsed?.commentNumber, undefined); - assert.strictEqual(issueSingleDigitParsed?.name, undefined); - assert.strictEqual(issueSingleDigitParsed?.owner, undefined); - const issueRepo = 'alexr00/myRepo#234'; - const issueRepoParsed = parseIssueExpressionOutput(issueRepo.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(issueRepoParsed?.issueNumber, 234); - assert.strictEqual(issueRepoParsed?.commentNumber, undefined); - assert.strictEqual(issueRepoParsed?.name, 'myRepo'); - assert.strictEqual(issueRepoParsed?.owner, 'alexr00'); - const issueUrl = 'http://github.com/alexr00/myRepo/issues/567'; - const issueUrlParsed = parseIssueExpressionOutput(issueUrl.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(issueUrlParsed?.issueNumber, 567); - assert.strictEqual(issueUrlParsed?.commentNumber, undefined); - assert.strictEqual(issueUrlParsed?.name, 'myRepo'); - assert.strictEqual(issueUrlParsed?.owner, 'alexr00'); - const commentUrl = 'https://github.com/microsoft/vscode/issues/96#issuecomment-641150523'; - const commentUrlParsed = parseIssueExpressionOutput(commentUrl.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(commentUrlParsed?.issueNumber, 96); - assert.strictEqual(commentUrlParsed?.commentNumber, 641150523); - assert.strictEqual(commentUrlParsed?.name, 'vscode'); - assert.strictEqual(commentUrlParsed?.owner, 'microsoft'); - const notIssue = '#a4'; - const notIssueParsed = parseIssueExpressionOutput(notIssue.match(ISSUE_OR_URL_EXPRESSION)); - assert.strictEqual(notIssueParsed, undefined); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { parseIssueExpressionOutput, ISSUE_OR_URL_EXPRESSION } from '../../github/utils'; + +describe('Issues utilities', function () { + it('regular expressions', async function () { + const issueNumber = '#1234'; + const issueNumberParsed = parseIssueExpressionOutput(issueNumber.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(issueNumberParsed?.issueNumber, 1234); + assert.strictEqual(issueNumberParsed?.commentNumber, undefined); + assert.strictEqual(issueNumberParsed?.name, undefined); + assert.strictEqual(issueNumberParsed?.owner, undefined); + + const issueNumberGH = 'GH-321'; + const issueNumberGHParsed = parseIssueExpressionOutput(issueNumberGH.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(issueNumberGHParsed?.issueNumber, 321); + assert.strictEqual(issueNumberGHParsed?.commentNumber, undefined); + assert.strictEqual(issueNumberGHParsed?.name, undefined); + assert.strictEqual(issueNumberGHParsed?.owner, undefined); + const issueSingleDigit = '#1'; + const issueSingleDigitParsed = parseIssueExpressionOutput(issueSingleDigit.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(issueSingleDigitParsed?.issueNumber, 1); + assert.strictEqual(issueSingleDigitParsed?.commentNumber, undefined); + assert.strictEqual(issueSingleDigitParsed?.name, undefined); + assert.strictEqual(issueSingleDigitParsed?.owner, undefined); + const issueRepo = 'alexr00/myRepo#234'; + const issueRepoParsed = parseIssueExpressionOutput(issueRepo.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(issueRepoParsed?.issueNumber, 234); + assert.strictEqual(issueRepoParsed?.commentNumber, undefined); + assert.strictEqual(issueRepoParsed?.name, 'myRepo'); + assert.strictEqual(issueRepoParsed?.owner, 'alexr00'); + const issueUrl = 'http://github.com/alexr00/myRepo/issues/567'; + const issueUrlParsed = parseIssueExpressionOutput(issueUrl.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(issueUrlParsed?.issueNumber, 567); + assert.strictEqual(issueUrlParsed?.commentNumber, undefined); + assert.strictEqual(issueUrlParsed?.name, 'myRepo'); + assert.strictEqual(issueUrlParsed?.owner, 'alexr00'); + const commentUrl = 'https://github.com/microsoft/vscode/issues/96#issuecomment-641150523'; + const commentUrlParsed = parseIssueExpressionOutput(commentUrl.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(commentUrlParsed?.issueNumber, 96); + assert.strictEqual(commentUrlParsed?.commentNumber, 641150523); + assert.strictEqual(commentUrlParsed?.name, 'vscode'); + assert.strictEqual(commentUrlParsed?.owner, 'microsoft'); + const notIssue = '#a4'; + const notIssueParsed = parseIssueExpressionOutput(notIssue.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(notIssueParsed, undefined); + }); +}); diff --git a/src/test/mocks/inMemoryMemento.ts b/src/test/mocks/inMemoryMemento.ts index db08795c42..f5930c89af 100644 --- a/src/test/mocks/inMemoryMemento.ts +++ b/src/test/mocks/inMemoryMemento.ts @@ -1,22 +1,23 @@ -import { Memento } from 'vscode'; - -export class InMemoryMemento implements Memento { - private _storage: { [keyName: string]: any } = {}; - - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; - get(key: string, defaultValue?: any) { - return this._storage[key] || defaultValue; - } - - update(key: string, value: any): Thenable { - this._storage[key] = value; - return Promise.resolve(); - } - - keys(): readonly string[] { - return Object.keys(this._storage); - } - - setKeysForSync(keys: string[]): void {} -} +import { Memento } from 'vscode'; + +export class InMemoryMemento implements Memento { + private _storage: { [keyName: string]: any } = {}; + + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: any) { + return this._storage[key] || defaultValue; + } + + update(key: string, value: any): Thenable { + this._storage[key] = value; + return Promise.resolve(); + } + + keys(): readonly string[] { + return Object.keys(this._storage); + } + + setKeysForSync(keys: string[]): void {} +} diff --git a/src/test/mocks/mockCommandRegistry.ts b/src/test/mocks/mockCommandRegistry.ts index 033120f42b..c98f1e0103 100644 --- a/src/test/mocks/mockCommandRegistry.ts +++ b/src/test/mocks/mockCommandRegistry.ts @@ -1,61 +1,62 @@ -import * as vscode from 'vscode'; -import { SinonSandbox } from 'sinon'; - -/** - * Intercept calls to `vscode.commands.registerCommand` for the lifetime of a Sinon sandbox. Store the - * registered commands locally for testing. - * - * Without this installed, functions that attempt to register commands that are also registered during extension - * activation will collide with those. - * - * @example - * describe('Class that registers commands', function() { - * let sinon: SinonSandbox; - * let commands: MockCommandRegistry; - * - * beforeEach(function() { - * sinon = createSandbox(); - * commands = new MockCommandRegistry(sinon); - * }); - * - * afterEach(function() { - * sinon.restore(); - * }); - * - * it('registers its command', function() { - * registerTheCommands(); - * - * assert.strictEqual(commands.executeCommand('identity.function', 1), 1); - * }); - * }); - */ -export class MockCommandRegistry { - private _commands: { [commandName: string]: (args: any[]) => any } = {}; - - static install(sinon: SinonSandbox) { - new this(sinon); - } - - constructor(sinon: SinonSandbox) { - sinon.stub(vscode.commands, 'registerCommand').callsFake(this.registerCommand.bind(this)); - } - - private registerCommand(commandID: string, callback: (args: any[]) => any) { - if (this._commands.hasOwnProperty(commandID)) { - throw new Error(`Duplicate command registration: ${commandID}`); - } - - this._commands[commandID] = callback; - return { - dispose: () => delete this._commands[commandID], - }; - } - - executeCommand(commandID: string, ...rest: any[]): any { - const callback = this._commands[commandID]; - if (!callback) { - throw new Error(`Unrecognized command execution: ${commandID}`); - } - return callback(rest); - } -} +import * as vscode from 'vscode'; +import { SinonSandbox } from 'sinon'; + +/** + * Intercept calls to `vscode.commands.registerCommand` for the lifetime of a Sinon sandbox. Store the + + * registered commands locally for testing. + * + * Without this installed, functions that attempt to register commands that are also registered during extension + * activation will collide with those. + * + * @example + * describe('Class that registers commands', function() { + * let sinon: SinonSandbox; + * let commands: MockCommandRegistry; + * + * beforeEach(function() { + * sinon = createSandbox(); + * commands = new MockCommandRegistry(sinon); + * }); + * + * afterEach(function() { + * sinon.restore(); + * }); + * + * it('registers its command', function() { + * registerTheCommands(); + * + * assert.strictEqual(commands.executeCommand('identity.function', 1), 1); + * }); + * }); + */ +export class MockCommandRegistry { + private _commands: { [commandName: string]: (args: any[]) => any } = {}; + + static install(sinon: SinonSandbox) { + new this(sinon); + } + + constructor(sinon: SinonSandbox) { + sinon.stub(vscode.commands, 'registerCommand').callsFake(this.registerCommand.bind(this)); + } + + private registerCommand(commandID: string, callback: (args: any[]) => any) { + if (this._commands.hasOwnProperty(commandID)) { + throw new Error(`Duplicate command registration: ${commandID}`); + } + + this._commands[commandID] = callback; + return { + dispose: () => delete this._commands[commandID], + }; + } + + executeCommand(commandID: string, ...rest: any[]): any { + const callback = this._commands[commandID]; + if (!callback) { + throw new Error(`Unrecognized command execution: ${commandID}`); + } + return callback(rest); + } +} diff --git a/src/test/mocks/mockExtensionContext.ts b/src/test/mocks/mockExtensionContext.ts index 47ef0287bf..c89ca94c32 100644 --- a/src/test/mocks/mockExtensionContext.ts +++ b/src/test/mocks/mockExtensionContext.ts @@ -1,61 +1,62 @@ -import * as path from 'path'; -import * as temp from 'temp'; -import { ExtensionContext, Uri, SecretStorage, Event, SecretStorageChangeEvent } from 'vscode'; - -import { InMemoryMemento } from './inMemoryMemento'; - -export class MockExtensionContext implements ExtensionContext { - extensionPath: string; - - workspaceState = new InMemoryMemento(); - globalState = new InMemoryMemento(); - secrets = new (class implements SecretStorage { - get(key: string): Thenable { - throw new Error('Method not implemented.'); - } - store(key: string, value: string): Thenable { - throw new Error('Method not implemented.'); - } - delete(key: string): Thenable { - throw new Error('Method not implemented.'); - } - onDidChange!: Event; - })(); - subscriptions: { dispose(): any }[] = []; - - storagePath: string; - globalStoragePath: string; - logPath: string; - extensionUri: Uri = Uri.file(path.resolve(__dirname, '..')); - environmentVariableCollection: any; - extensionMode: any; - - logUri: Uri; - - storageUri: Uri; - - globalStorageUri: Uri; - - extensionRuntime: any; - extension: any; - isNewInstall: any; - - constructor() { - this.extensionPath = path.resolve(__dirname, '..'); - this.extensionUri = Uri.file(this.extensionPath); - this.storagePath = temp.mkdirSync('storage-path'); - this.storageUri = Uri.file(this.storagePath); - this.globalStoragePath = temp.mkdirSync('global-storage-path'); - this.globalStorageUri = Uri.file(this.globalStoragePath); - this.logPath = temp.mkdirSync('log-path'); - this.logUri = Uri.file(this.logPath); - } - - asAbsolutePath(relativePath: string): string { - return path.resolve(this.extensionPath, relativePath); - } - - dispose() { - this.subscriptions.forEach(sub => sub.dispose()); - } -} +import * as path from 'path'; +import * as temp from 'temp'; +import { ExtensionContext, Uri, SecretStorage, Event, SecretStorageChangeEvent } from 'vscode'; + +import { InMemoryMemento } from './inMemoryMemento'; + + +export class MockExtensionContext implements ExtensionContext { + extensionPath: string; + + workspaceState = new InMemoryMemento(); + globalState = new InMemoryMemento(); + secrets = new (class implements SecretStorage { + get(key: string): Thenable { + throw new Error('Method not implemented.'); + } + store(key: string, value: string): Thenable { + throw new Error('Method not implemented.'); + } + delete(key: string): Thenable { + throw new Error('Method not implemented.'); + } + onDidChange!: Event; + })(); + subscriptions: { dispose(): any }[] = []; + + storagePath: string; + globalStoragePath: string; + logPath: string; + extensionUri: Uri = Uri.file(path.resolve(__dirname, '..')); + environmentVariableCollection: any; + extensionMode: any; + + logUri: Uri; + + storageUri: Uri; + + globalStorageUri: Uri; + + extensionRuntime: any; + extension: any; + isNewInstall: any; + + constructor() { + this.extensionPath = path.resolve(__dirname, '..'); + this.extensionUri = Uri.file(this.extensionPath); + this.storagePath = temp.mkdirSync('storage-path'); + this.storageUri = Uri.file(this.storagePath); + this.globalStoragePath = temp.mkdirSync('global-storage-path'); + this.globalStorageUri = Uri.file(this.globalStoragePath); + this.logPath = temp.mkdirSync('log-path'); + this.logUri = Uri.file(this.logPath); + } + + asAbsolutePath(relativePath: string): string { + return path.resolve(this.extensionPath, relativePath); + } + + dispose() { + this.subscriptions.forEach(sub => sub.dispose()); + } +} diff --git a/src/test/mocks/mockGitHubRepository.ts b/src/test/mocks/mockGitHubRepository.ts index 27ef898430..c97af9ddcb 100644 --- a/src/test/mocks/mockGitHubRepository.ts +++ b/src/test/mocks/mockGitHubRepository.ts @@ -1,159 +1,160 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { SinonSandbox } from 'sinon'; -import { QueryOptions, ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, OperationVariables } from 'apollo-boost'; - -import { GitHubRepository } from '../../github/githubRepository'; -import { QueryProvider } from './queryProvider'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { CredentialStore } from '../../github/credentials'; -import { RepositoryBuilder } from '../builders/rest/repoBuilder'; -import { UserBuilder } from '../builders/rest/userBuilder'; -import { - ManagedGraphQLPullRequestBuilder, - ManagedRESTPullRequestBuilder, - ManagedPullRequest, -} from '../builders/managedPullRequestBuilder'; -import { MockTelemetry } from './mockTelemetry'; -import { Uri } from 'vscode'; -import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; -import { mergeQuerySchemaWithShared } from '../../github/common'; -const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; - -export class MockGitHubRepository extends GitHubRepository { - readonly queryProvider: QueryProvider; - - constructor(remote: GitHubRemote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { - super(remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); - - this.queryProvider = new QueryProvider(sinon); - - this._hub = { - octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry(), true)), - graphql: null, - }; - - this._metadata = { - ...new RepositoryBuilder().build(), - currentUser: new UserBuilder().build(), - }; - - this._initialized = true; - } - - async ensure() { - return this; - } - - query = async (query: QueryOptions): Promise> => - this.queryProvider.emulateGraphQLQuery(query); - - mutate = async (mutation: MutationOptions): Promise> => - this.queryProvider.emulateGraphQLMutation(mutation); - - buildMetadata(block: (repoBuilder: RepositoryBuilder, userBuilder: UserBuilder) => void) { - const repoBuilder = new RepositoryBuilder(); - const userBuilder = new UserBuilder(); - block(repoBuilder, userBuilder); - this._metadata = { - ...repoBuilder.build(), - currentUser: userBuilder.build(), - }; - } - - addGraphQLPullRequest(block: (builder: ManagedGraphQLPullRequestBuilder) => void): ManagedPullRequest<'graphql'> { - const builder = new ManagedGraphQLPullRequestBuilder(); - block(builder); - const responses = builder.build(); - - const prNumber = responses.pullRequest.repository.pullRequest.number; - const headRef = responses.pullRequest.repository.pullRequest.headRef; - - this.queryProvider.expectGraphQLQuery( - { - query: queries.PullRequest, - variables: { - owner: this.remote.owner, - name: this.remote.repositoryName, - number: prNumber, - }, - }, - { data: responses.pullRequest, loading: false, stale: false, networkStatus: NetworkStatus.ready }, - ); - - this.queryProvider.expectGraphQLQuery( - { - query: queries.TimelineEvents, - variables: { - owner: this.remote.owner, - name: this.remote.repositoryName, - number: prNumber, - }, - }, - { data: responses.timelineEvents, loading: false, stale: false, networkStatus: NetworkStatus.ready }, - ); - - this.queryProvider.expectGraphQLQuery( - { - query: queries.LatestReviewCommit, - variables: { - owner: this.remote.owner, - name: this.remote.repositoryName, - number: prNumber, - } - }, - { data: responses.latestReviewCommit, loading: false, stale: false, networkStatus: NetworkStatus.ready }, - ) - - this._addPullRequestCommon(prNumber, headRef && headRef.target.oid, responses); - - return responses; - } - - addRESTPullRequest(block: (builder: ManagedRESTPullRequestBuilder) => void): ManagedPullRequest<'rest'> { - const builder = new ManagedRESTPullRequestBuilder(); - block(builder); - const responses = builder.build(); - - const prNumber = responses.pullRequest.number; - const headRef = responses.pullRequest.head.sha; - - this.queryProvider.expectOctokitRequest( - ['pullRequests', 'get'], - [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], - responses.pullRequest, - ); - this.queryProvider.expectOctokitRequest( - ['issues', 'getEventsTimeline'], - [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], - responses.timelineEvents, - ); - - this._addPullRequestCommon(prNumber, headRef, responses); - - return responses; - } - - private _addPullRequestCommon(prNumber: number, headRef: string | undefined, responses: ManagedPullRequest) { - this.queryProvider.expectOctokitRequest( - ['repos', 'get'], - [{ owner: this.remote.owner, repo: this.remote.repositoryName }], - responses.repositoryREST, - ); - if (headRef) { - this.queryProvider.expectOctokitRequest( - ['repos', 'getCombinedStatusForRef'], - [{ owner: this.remote.owner, repo: this.remote.repositoryName, ref: headRef }], - responses.combinedStatusREST, - ); - } - this.queryProvider.expectOctokitRequest( - ['pulls', 'listReviewRequests'], - [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], - responses.reviewRequestsREST, - ); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { SinonSandbox } from 'sinon'; +import { QueryOptions, ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, OperationVariables } from 'apollo-boost'; + +import { GitHubRepository } from '../../github/githubRepository'; +import { QueryProvider } from './queryProvider'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { CredentialStore } from '../../github/credentials'; +import { RepositoryBuilder } from '../builders/rest/repoBuilder'; +import { UserBuilder } from '../builders/rest/userBuilder'; +import { + ManagedGraphQLPullRequestBuilder, + ManagedRESTPullRequestBuilder, + ManagedPullRequest, +} from '../builders/managedPullRequestBuilder'; +import { MockTelemetry } from './mockTelemetry'; +import { Uri } from 'vscode'; +import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; + +export class MockGitHubRepository extends GitHubRepository { + readonly queryProvider: QueryProvider; + + constructor(remote: GitHubRemote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { + super(remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); + + this.queryProvider = new QueryProvider(sinon); + + this._hub = { + octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry(), true)), + graphql: null, + }; + + this._metadata = { + ...new RepositoryBuilder().build(), + currentUser: new UserBuilder().build(), + }; + + this._initialized = true; + } + + async ensure() { + return this; + } + + query = async (query: QueryOptions): Promise> => + this.queryProvider.emulateGraphQLQuery(query); + + mutate = async (mutation: MutationOptions): Promise> => + this.queryProvider.emulateGraphQLMutation(mutation); + + buildMetadata(block: (repoBuilder: RepositoryBuilder, userBuilder: UserBuilder) => void) { + const repoBuilder = new RepositoryBuilder(); + const userBuilder = new UserBuilder(); + block(repoBuilder, userBuilder); + this._metadata = { + ...repoBuilder.build(), + currentUser: userBuilder.build(), + }; + } + + addGraphQLPullRequest(block: (builder: ManagedGraphQLPullRequestBuilder) => void): ManagedPullRequest<'graphql'> { + const builder = new ManagedGraphQLPullRequestBuilder(); + block(builder); + const responses = builder.build(); + + const prNumber = responses.pullRequest.repository.pullRequest.number; + const headRef = responses.pullRequest.repository.pullRequest.headRef; + + this.queryProvider.expectGraphQLQuery( + { + query: queries.PullRequest, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + }, + }, + { data: responses.pullRequest, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ); + + this.queryProvider.expectGraphQLQuery( + { + query: queries.TimelineEvents, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + }, + }, + { data: responses.timelineEvents, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ); + + this.queryProvider.expectGraphQLQuery( + { + query: queries.LatestReviewCommit, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + } + }, + { data: responses.latestReviewCommit, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ) + + this._addPullRequestCommon(prNumber, headRef && headRef.target.oid, responses); + + return responses; + } + + addRESTPullRequest(block: (builder: ManagedRESTPullRequestBuilder) => void): ManagedPullRequest<'rest'> { + const builder = new ManagedRESTPullRequestBuilder(); + block(builder); + const responses = builder.build(); + + const prNumber = responses.pullRequest.number; + const headRef = responses.pullRequest.head.sha; + + this.queryProvider.expectOctokitRequest( + ['pullRequests', 'get'], + [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], + responses.pullRequest, + ); + this.queryProvider.expectOctokitRequest( + ['issues', 'getEventsTimeline'], + [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], + responses.timelineEvents, + ); + + this._addPullRequestCommon(prNumber, headRef, responses); + + return responses; + } + + private _addPullRequestCommon(prNumber: number, headRef: string | undefined, responses: ManagedPullRequest) { + this.queryProvider.expectOctokitRequest( + ['repos', 'get'], + [{ owner: this.remote.owner, repo: this.remote.repositoryName }], + responses.repositoryREST, + ); + if (headRef) { + this.queryProvider.expectOctokitRequest( + ['repos', 'getCombinedStatusForRef'], + [{ owner: this.remote.owner, repo: this.remote.repositoryName, ref: headRef }], + responses.combinedStatusREST, + ); + } + this.queryProvider.expectOctokitRequest( + ['pulls', 'listReviewRequests'], + [{ owner: this.remote.owner, repo: this.remote.repositoryName, number: prNumber }], + responses.reviewRequestsREST, + ); + } +} diff --git a/src/test/mocks/mockRepository.ts b/src/test/mocks/mockRepository.ts index 8d719ac72f..537063ae30 100644 --- a/src/test/mocks/mockRepository.ts +++ b/src/test/mocks/mockRepository.ts @@ -1,322 +1,323 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Uri } from 'vscode'; -import { RefType } from '../../api/api1'; - -import type { - Repository, - RepositoryState, - RepositoryUIState, - Commit, - Change, - Branch, - CommitOptions, - InputBox, - Ref, - BranchQuery, - FetchOptions, - RefQuery, -} from '../../api/api'; - -type Mutable = { - -readonly [P in keyof T]: T[P]; -}; - -export class MockRepository implements Repository { - add(paths: string[]): Promise { - return Promise.reject(new Error(`Unexpected add(${paths.join(', ')})`)); - } - commit(message: string, opts?: CommitOptions): Promise { - return Promise.reject(new Error(`Unexpected commit(${message}, ${opts})`)); - } - renameRemote(name: string, newName: string): Promise { - return Promise.reject(new Error(`Unexpected renameRemote (${name}, ${newName})`)); - } - getGlobalConfig(key: string): Promise { - return Promise.reject(new Error(`Unexpected getGlobalConfig(${key})`)); - } - detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string | undefined }> { - return Promise.reject(new Error(`Unexpected detectObjectType(${object})`)); - } - buffer(ref: string, path: string): Promise { - return Promise.reject(new Error(`Unexpected buffer(${ref}, ${path})`)); - } - clean(paths: string[]): Promise { - return Promise.reject(new Error(`Unexpected clean(${paths})`)); - } - diffWithHEAD(path?: any): any { - return Promise.reject(new Error(`Unexpected diffWithHEAD(${path})`)); - } - diffIndexWithHEAD(path?: any): any { - return Promise.reject(new Error(`Unexpected diffIndexWithHEAD(${path})`)); - } - diffIndexWith(ref: any, path?: any): any { - return Promise.reject(new Error(`Unexpected diffIndexWith(${ref}, ${path})`)); - } - getMergeBase(ref1: string, ref2: string): Promise { - return Promise.reject(new Error(`Unexpected getMergeBase(${ref1}, ${ref2})`)); - } - async getRefs(_query: RefQuery, _cancellationToken?: any): Promise { - // ignore the query - return this._state.refs; - } - log(options?: any): Promise { - return Promise.reject(new Error(`Unexpected log(${options})`)); - } - - private _state: Mutable = { - HEAD: undefined, - refs: [], - remotes: [], - submodules: [], - rebaseCommit: undefined, - mergeChanges: [], - indexChanges: [], - workingTreeChanges: [], - onDidChange: () => ({ dispose() { } }), - }; - private _config: Map = new Map(); - private _branches: Branch[] = []; - private _expectedFetches: { remoteName?: string; ref?: string; depth?: number }[] = []; - private _expectedPulls: { unshallow?: boolean }[] = []; - private _expectedPushes: { remoteName?: string; branchName?: string; setUpstream?: boolean }[] = []; - - inputBox: InputBox = { value: '' }; - - rootUri = Uri.file('/root'); - - state: RepositoryState = this._state; - - ui: RepositoryUIState = { - selected: true, - onDidChange: () => ({ dispose() { } }), - }; - - async getConfigs(): Promise<{ key: string; value: string }[]> { - return Array.from(this._config, ([k, v]) => ({ key: k, value: v })); - } - - async getConfig(key: string): Promise { - return this._config.get(key) || ''; - } - - async setConfig(key: string, value: string): Promise { - const oldValue = this._config.get(key) || ''; - this._config.set(key, value); - return oldValue; - } - - getObjectDetails(treeish: string, treePath: string): Promise<{ mode: string; object: string; size: number }> { - return Promise.reject(new Error(`Unexpected getObjectDetails(${treeish}, ${treePath})`)); - } - - show(ref: string, treePath: string): Promise { - return Promise.reject(new Error(`Unexpected show(${ref}, ${treePath})`)); - } - - getCommit(ref: string): Promise { - return Promise.reject(new Error(`Unexpected getCommit(${ref})`)); - } - - apply(patch: string, reverse?: boolean | undefined): Promise { - return Promise.reject(new Error(`Unexpected apply(..., ${reverse})`)); - } - - diff(cached?: boolean | undefined): Promise { - return Promise.reject(new Error(`Unexpected diff(${cached})`)); - } - - diffWith(ref: string): Promise; - diffWith(ref: string, treePath: string): Promise; - diffWith(ref: string, treePath?: string) { - return Promise.reject(new Error(`Unexpected diffWith(${ref}, ${treePath})`)); - } - - diffBlobs(object1: string, object2: string): Promise { - return Promise.reject(new Error(`Unexpected diffBlobs(${object1}, ${object2})`)); - } - - diffBetween(ref1: string, ref2: string): Promise; - diffBetween(ref1: string, ref2: string, treePath: string): Promise; - diffBetween(ref1: string, ref2: string, treePath?: string) { - return Promise.reject(new Error(`Unexpected diffBlobs(${ref1}, ${ref2}, ${treePath})`)); - } - - hashObject(data: string): Promise { - return Promise.reject(new Error('Unexpected hashObject(...)')); - } - - async createBranch(name: string, checkout: boolean, ref?: string | undefined): Promise { - if (this._branches.some(b => b.name === name)) { - throw new Error(`A branch named ${name} already exists`); - } - - const branch = { - type: RefType.Head, - name, - commit: ref, - }; - - if (checkout) { - this._state.HEAD = branch; - } - - this._state.refs.push(branch); - this._branches.push(branch); - } - - async deleteBranch(name: string, force?: boolean | undefined): Promise { - const index = this._branches.findIndex(b => b.name === name); - if (index === -1) { - throw new Error(`Attempt to delete nonexistent branch ${name}`); - } - this._branches.splice(index, 1); - } - - async getBranch(name: string): Promise { - const branch = this._branches.find(b => b.name === name); - if (!branch) { - throw new Error(`getBranch called with unrecognized name "${name}"`); - } - return branch; - } - - async getBranches(_query: BranchQuery): Promise { - return []; - } - - async setBranchUpstream(name: string, upstream: string): Promise { - const index = this._branches.findIndex(b => b.name === name); - if (index === -1) { - throw new Error(`setBranchUpstream called with unrecognized branch name ${name})`); - } - - const match = /^refs\/remotes\/([^\/]+)\/(.+)$/.exec(upstream); - if (!match) { - throw new Error( - `upstream ${upstream} provided to setBranchUpstream did match pattern refs/remotes//`, - ); - } - const [, remoteName, remoteRef] = match; - - const existing = this._branches[index]; - const replacement = { - ...existing, - upstream: { - remote: remoteName, - name: remoteRef, - }, - }; - this._branches.splice(index, 1, replacement); - - if (this._state.HEAD === existing) { - this._state.HEAD = replacement; - } - } - - status(): Promise { - return Promise.reject(new Error('Unexpected status()')); - } - - async checkout(treeish: string): Promise { - const branch = this._branches.find(b => b.name === treeish); - - // Also: tags - - if (!branch) { - throw new Error(`checked called with unrecognized ref ${treeish}`); - } - - this._state.HEAD = branch; - } - - async addRemote(name: string, url: string): Promise { - if (this._state.remotes.some(r => r.name === name)) { - throw new Error(`A remote named ${name} already exists.`); - } - - this._state.remotes.push({ - name, - fetchUrl: url, - pushUrl: url, - isReadOnly: false, - }); - } - - async removeRemote(name: string): Promise { - const index = this._state.remotes.findIndex(r => r.name === name); - if (index === -1) { - throw new Error(`No remote named ${name} exists.`); - } - this._state.remotes.splice(index, 1); - } - - async fetch(arg0?: string | undefined | FetchOptions, ref?: string | undefined, depth?: number | undefined): Promise { - let remoteName: string | undefined; - if (typeof arg0 === 'object') { - remoteName = arg0.remote; - ref = arg0.ref; - depth = arg0.depth; - } else { - remoteName = arg0; - } - - const index = this._expectedFetches.findIndex( - f => f.remoteName === remoteName && f.ref === ref && f.depth === depth, - ); - if (index === -1) { - throw new Error(`Unexpected fetch(${remoteName}, ${ref}, ${depth})`); - } - - if (ref) { - const match = /^(?:\+?[^:]+\:)?(.*)$/.exec(ref); - if (match) { - const [, localRef] = match; - await this.createBranch(localRef, false); - } - } - - this._expectedFetches.splice(index, 1); - } - - async pull(unshallow?: boolean | undefined): Promise { - const index = this._expectedPulls.findIndex(f => f.unshallow === unshallow); - if (index === -1) { - throw new Error(`Unexpected pull(${unshallow})`); - } - this._expectedPulls.splice(index, 1); - } - - async push( - remoteName?: string | undefined, - branchName?: string | undefined, - setUpstream?: boolean | undefined, - ): Promise { - const index = this._expectedPushes.findIndex( - f => f.remoteName === remoteName && f.branchName === branchName && f.setUpstream === setUpstream, - ); - if (index === -1) { - throw new Error(`Unexpected push(${remoteName}, ${branchName}, ${setUpstream})`); - } - this._expectedPushes.splice(index, 1); - } - - blame(treePath: string): Promise { - return Promise.reject(new Error(`Unexpected blame(${treePath})`)); - } - - expectFetch(remoteName?: string, ref?: string, depth?: number) { - this._expectedFetches.push({ remoteName, ref, depth }); - } - - expectPull(unshallow?: boolean) { - this._expectedPulls.push({ unshallow }); - } - - expectPush(remoteName?: string, branchName?: string, setUpstream?: boolean) { - this._expectedPushes.push({ remoteName, branchName, setUpstream }); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Uri } from 'vscode'; +import { RefType } from '../../api/api1'; + +import type { + Repository, + RepositoryState, + RepositoryUIState, + Commit, + Change, + Branch, + CommitOptions, + InputBox, + Ref, + BranchQuery, + FetchOptions, + RefQuery, +} from '../../api/api'; + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +export class MockRepository implements Repository { + add(paths: string[]): Promise { + return Promise.reject(new Error(`Unexpected add(${paths.join(', ')})`)); + } + commit(message: string, opts?: CommitOptions): Promise { + return Promise.reject(new Error(`Unexpected commit(${message}, ${opts})`)); + } + renameRemote(name: string, newName: string): Promise { + return Promise.reject(new Error(`Unexpected renameRemote (${name}, ${newName})`)); + } + getGlobalConfig(key: string): Promise { + return Promise.reject(new Error(`Unexpected getGlobalConfig(${key})`)); + } + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string | undefined }> { + return Promise.reject(new Error(`Unexpected detectObjectType(${object})`)); + } + buffer(ref: string, path: string): Promise { + return Promise.reject(new Error(`Unexpected buffer(${ref}, ${path})`)); + } + clean(paths: string[]): Promise { + return Promise.reject(new Error(`Unexpected clean(${paths})`)); + } + diffWithHEAD(path?: any): any { + return Promise.reject(new Error(`Unexpected diffWithHEAD(${path})`)); + } + diffIndexWithHEAD(path?: any): any { + return Promise.reject(new Error(`Unexpected diffIndexWithHEAD(${path})`)); + } + diffIndexWith(ref: any, path?: any): any { + return Promise.reject(new Error(`Unexpected diffIndexWith(${ref}, ${path})`)); + } + getMergeBase(ref1: string, ref2: string): Promise { + return Promise.reject(new Error(`Unexpected getMergeBase(${ref1}, ${ref2})`)); + } + async getRefs(_query: RefQuery, _cancellationToken?: any): Promise { + // ignore the query + return this._state.refs; + } + log(options?: any): Promise { + return Promise.reject(new Error(`Unexpected log(${options})`)); + } + + private _state: Mutable = { + HEAD: undefined, + refs: [], + remotes: [], + submodules: [], + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: () => ({ dispose() { } }), + }; + private _config: Map = new Map(); + private _branches: Branch[] = []; + private _expectedFetches: { remoteName?: string; ref?: string; depth?: number }[] = []; + private _expectedPulls: { unshallow?: boolean }[] = []; + private _expectedPushes: { remoteName?: string; branchName?: string; setUpstream?: boolean }[] = []; + + inputBox: InputBox = { value: '' }; + + rootUri = Uri.file('/root'); + + state: RepositoryState = this._state; + + ui: RepositoryUIState = { + selected: true, + onDidChange: () => ({ dispose() { } }), + }; + + async getConfigs(): Promise<{ key: string; value: string }[]> { + return Array.from(this._config, ([k, v]) => ({ key: k, value: v })); + } + + async getConfig(key: string): Promise { + return this._config.get(key) || ''; + } + + async setConfig(key: string, value: string): Promise { + const oldValue = this._config.get(key) || ''; + this._config.set(key, value); + return oldValue; + } + + getObjectDetails(treeish: string, treePath: string): Promise<{ mode: string; object: string; size: number }> { + return Promise.reject(new Error(`Unexpected getObjectDetails(${treeish}, ${treePath})`)); + } + + show(ref: string, treePath: string): Promise { + return Promise.reject(new Error(`Unexpected show(${ref}, ${treePath})`)); + } + + getCommit(ref: string): Promise { + return Promise.reject(new Error(`Unexpected getCommit(${ref})`)); + } + + apply(patch: string, reverse?: boolean | undefined): Promise { + return Promise.reject(new Error(`Unexpected apply(..., ${reverse})`)); + } + + diff(cached?: boolean | undefined): Promise { + return Promise.reject(new Error(`Unexpected diff(${cached})`)); + } + + diffWith(ref: string): Promise; + diffWith(ref: string, treePath: string): Promise; + diffWith(ref: string, treePath?: string) { + return Promise.reject(new Error(`Unexpected diffWith(${ref}, ${treePath})`)); + } + + diffBlobs(object1: string, object2: string): Promise { + return Promise.reject(new Error(`Unexpected diffBlobs(${object1}, ${object2})`)); + } + + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, treePath: string): Promise; + diffBetween(ref1: string, ref2: string, treePath?: string) { + return Promise.reject(new Error(`Unexpected diffBlobs(${ref1}, ${ref2}, ${treePath})`)); + } + + hashObject(data: string): Promise { + return Promise.reject(new Error('Unexpected hashObject(...)')); + } + + async createBranch(name: string, checkout: boolean, ref?: string | undefined): Promise { + if (this._branches.some(b => b.name === name)) { + throw new Error(`A branch named ${name} already exists`); + } + + const branch = { + type: RefType.Head, + name, + commit: ref, + }; + + if (checkout) { + this._state.HEAD = branch; + } + + this._state.refs.push(branch); + this._branches.push(branch); + } + + async deleteBranch(name: string, force?: boolean | undefined): Promise { + const index = this._branches.findIndex(b => b.name === name); + if (index === -1) { + throw new Error(`Attempt to delete nonexistent branch ${name}`); + } + this._branches.splice(index, 1); + } + + async getBranch(name: string): Promise { + const branch = this._branches.find(b => b.name === name); + if (!branch) { + throw new Error(`getBranch called with unrecognized name "${name}"`); + } + return branch; + } + + async getBranches(_query: BranchQuery): Promise { + return []; + } + + async setBranchUpstream(name: string, upstream: string): Promise { + const index = this._branches.findIndex(b => b.name === name); + if (index === -1) { + throw new Error(`setBranchUpstream called with unrecognized branch name ${name})`); + } + + const match = /^refs\/remotes\/([^\/]+)\/(.+)$/.exec(upstream); + if (!match) { + throw new Error( + `upstream ${upstream} provided to setBranchUpstream did match pattern refs/remotes//`, + ); + } + const [, remoteName, remoteRef] = match; + + const existing = this._branches[index]; + const replacement = { + ...existing, + upstream: { + remote: remoteName, + name: remoteRef, + }, + }; + this._branches.splice(index, 1, replacement); + + if (this._state.HEAD === existing) { + this._state.HEAD = replacement; + } + } + + status(): Promise { + return Promise.reject(new Error('Unexpected status()')); + } + + async checkout(treeish: string): Promise { + const branch = this._branches.find(b => b.name === treeish); + + // Also: tags + + if (!branch) { + throw new Error(`checked called with unrecognized ref ${treeish}`); + } + + this._state.HEAD = branch; + } + + async addRemote(name: string, url: string): Promise { + if (this._state.remotes.some(r => r.name === name)) { + throw new Error(`A remote named ${name} already exists.`); + } + + this._state.remotes.push({ + name, + fetchUrl: url, + pushUrl: url, + isReadOnly: false, + }); + } + + async removeRemote(name: string): Promise { + const index = this._state.remotes.findIndex(r => r.name === name); + if (index === -1) { + throw new Error(`No remote named ${name} exists.`); + } + this._state.remotes.splice(index, 1); + } + + async fetch(arg0?: string | undefined | FetchOptions, ref?: string | undefined, depth?: number | undefined): Promise { + let remoteName: string | undefined; + if (typeof arg0 === 'object') { + remoteName = arg0.remote; + ref = arg0.ref; + depth = arg0.depth; + } else { + remoteName = arg0; + } + + const index = this._expectedFetches.findIndex( + f => f.remoteName === remoteName && f.ref === ref && f.depth === depth, + ); + if (index === -1) { + throw new Error(`Unexpected fetch(${remoteName}, ${ref}, ${depth})`); + } + + if (ref) { + const match = /^(?:\+?[^:]+\:)?(.*)$/.exec(ref); + if (match) { + const [, localRef] = match; + await this.createBranch(localRef, false); + } + } + + this._expectedFetches.splice(index, 1); + } + + async pull(unshallow?: boolean | undefined): Promise { + const index = this._expectedPulls.findIndex(f => f.unshallow === unshallow); + if (index === -1) { + throw new Error(`Unexpected pull(${unshallow})`); + } + this._expectedPulls.splice(index, 1); + } + + async push( + remoteName?: string | undefined, + branchName?: string | undefined, + setUpstream?: boolean | undefined, + ): Promise { + const index = this._expectedPushes.findIndex( + f => f.remoteName === remoteName && f.branchName === branchName && f.setUpstream === setUpstream, + ); + if (index === -1) { + throw new Error(`Unexpected push(${remoteName}, ${branchName}, ${setUpstream})`); + } + this._expectedPushes.splice(index, 1); + } + + blame(treePath: string): Promise { + return Promise.reject(new Error(`Unexpected blame(${treePath})`)); + } + + expectFetch(remoteName?: string, ref?: string, depth?: number) { + this._expectedFetches.push({ remoteName, ref, depth }); + } + + expectPull(unshallow?: boolean) { + this._expectedPulls.push({ unshallow }); + } + + expectPush(remoteName?: string, branchName?: string, setUpstream?: boolean) { + this._expectedPushes.push({ remoteName, branchName, setUpstream }); + } +} diff --git a/src/test/mocks/mockTelemetry.ts b/src/test/mocks/mockTelemetry.ts index ad426ff8a3..9f577890f5 100644 --- a/src/test/mocks/mockTelemetry.ts +++ b/src/test/mocks/mockTelemetry.ts @@ -1,9 +1,10 @@ -import { ITelemetry } from '../../common/telemetry'; - -export class MockTelemetry implements ITelemetry { - sendTelemetryEvent() {} - sendTelemetryErrorEvent() {} - dispose() { - return Promise.resolve(); - } -} +import { ITelemetry } from '../../common/telemetry'; + +export class MockTelemetry implements ITelemetry { + sendTelemetryEvent() {} + sendTelemetryErrorEvent() {} + + dispose() { + return Promise.resolve(); + } +} diff --git a/src/test/mocks/mockWebviewEnvironment.ts b/src/test/mocks/mockWebviewEnvironment.ts index e3c19af551..c634c2c66e 100644 --- a/src/test/mocks/mockWebviewEnvironment.ts +++ b/src/test/mocks/mockWebviewEnvironment.ts @@ -1,87 +1,88 @@ -import installJsDomGlobal from 'jsdom-global'; -import { Suite } from 'mocha'; - -interface WebviewEnvironmentSetters { - stateSetter(newState: any): void; - stateGetter(): any; - messageAdder(newMessage: any): void; -} - -class WebviewVsCodeApi { - constructor(private readonly _callbacks: WebviewEnvironmentSetters) { } - - postMessage(message: any) { - this._callbacks.messageAdder(message); - } - - setState(state: any) { - this._callbacks.stateSetter(state); - } - - getState() { - return this._callbacks.stateGetter(); - } -} - -class MockWebviewEnvironment { - private readonly _api: WebviewVsCodeApi; - private readonly _messages: any[] = []; - private _persistedState: any; - private _uninstall: () => void; - - constructor() { - this._api = new WebviewVsCodeApi({ - stateSetter: nState => { - this._persistedState = nState; - }, - stateGetter: () => this._persistedState, - messageAdder: newMessage => { - this._messages.push(newMessage); - }, - }); - - this._uninstall = () => { }; - } - - install(host: any) { - const previous = host.acquireVsCodeApi; - host.acquireVsCodeApi = () => this._api; - this._uninstall = () => { - if (previous) { - host.acquireVsCodeApi = previous; - } else { - delete host.acquireVsCodeApi; - } - }; - } - - uninstall() { - this._uninstall(); - } - - /** - * Install before and after hooks to configure a Mocha test suite to use this Webview environment. - * - * @param suite The test suite context. - * - * @example - * describe('SomeComponent', function () { - * mockWebviewEnvironment.use(this); - * - * it('does something'); - * }); - */ - use(suite: Suite) { - suite.beforeAll(() => this.install(global)); - suite.afterAll(() => this.uninstall()); - } - - /** - * Return the most recently persisted state from the Webview. - */ - getPersistedState() { - return this._persistedState; - } -} - -export const mockWebviewEnvironment = new MockWebviewEnvironment(); +import installJsDomGlobal from 'jsdom-global'; +import { Suite } from 'mocha'; + +interface WebviewEnvironmentSetters { + stateSetter(newState: any): void; + + stateGetter(): any; + messageAdder(newMessage: any): void; +} + +class WebviewVsCodeApi { + constructor(private readonly _callbacks: WebviewEnvironmentSetters) { } + + postMessage(message: any) { + this._callbacks.messageAdder(message); + } + + setState(state: any) { + this._callbacks.stateSetter(state); + } + + getState() { + return this._callbacks.stateGetter(); + } +} + +class MockWebviewEnvironment { + private readonly _api: WebviewVsCodeApi; + private readonly _messages: any[] = []; + private _persistedState: any; + private _uninstall: () => void; + + constructor() { + this._api = new WebviewVsCodeApi({ + stateSetter: nState => { + this._persistedState = nState; + }, + stateGetter: () => this._persistedState, + messageAdder: newMessage => { + this._messages.push(newMessage); + }, + }); + + this._uninstall = () => { }; + } + + install(host: any) { + const previous = host.acquireVsCodeApi; + host.acquireVsCodeApi = () => this._api; + this._uninstall = () => { + if (previous) { + host.acquireVsCodeApi = previous; + } else { + delete host.acquireVsCodeApi; + } + }; + } + + uninstall() { + this._uninstall(); + } + + /** + * Install before and after hooks to configure a Mocha test suite to use this Webview environment. + * + * @param suite The test suite context. + * + * @example + * describe('SomeComponent', function () { + * mockWebviewEnvironment.use(this); + * + * it('does something'); + * }); + */ + use(suite: Suite) { + suite.beforeAll(() => this.install(global)); + suite.afterAll(() => this.uninstall()); + } + + /** + * Return the most recently persisted state from the Webview. + */ + getPersistedState() { + return this._persistedState; + } +} + +export const mockWebviewEnvironment = new MockWebviewEnvironment(); diff --git a/src/test/mocks/queryProvider.ts b/src/test/mocks/queryProvider.ts index 5a3834d393..e2a18bffe0 100644 --- a/src/test/mocks/queryProvider.ts +++ b/src/test/mocks/queryProvider.ts @@ -1,134 +1,135 @@ -import { inspect } from 'util'; -import { Octokit } from '@octokit/rest'; -import { - ApolloQueryResult, - QueryOptions, - DocumentNode, - OperationVariables, - MutationOptions, - FetchResult, -} from 'apollo-boost'; -import { SinonSandbox, SinonStubbedInstance } from 'sinon'; -import equals from 'fast-deep-equal'; - -interface RecordedQueryResult { - variables?: OperationVariables; - result: ApolloQueryResult; -} - -interface RecordedMutationResult { - variables?: OperationVariables; - result: FetchResult; -} - -export class QueryProvider { - private _graphqlQueryResponses: Map[]>; - private _graphqlMutationResponses: Map[]>; - private _octokit: SinonStubbedInstance; - - constructor(private _sinon: SinonSandbox) { - this._graphqlQueryResponses = new Map(); - this._graphqlMutationResponses = new Map(); - - // Create the stubbed Octokit instance indirectly like this, rather than using `this._sinon.createStubbedInstance()`, - // because the exported Octokit function is actually a bound constructor method. `Object.getPrototypeOf(Octokit)` returns - // the correct prototype, but `Octokit.prototype` does not. - this._octokit = this._sinon.stub(Object.create(Object.getPrototypeOf(Octokit))); - } - - get octokit(): Octokit { - // Cast through "any" because SinonStubbedInstance does not properly map the type of the - // overloaded "authenticate" method. - return (this._octokit as any) as Octokit; - } - - expectGraphQLQuery(q: QueryOptions, result: ApolloQueryResult) { - if (!q.query) { - throw new Error('Empty GraphQL query used in expectation. Is the GraphQL loader configured properly?'); - } - - const cannedResponse: RecordedQueryResult = { variables: q.variables, result }; - - const cannedResponses = this._graphqlQueryResponses.get(q.query) || []; - if (cannedResponses.length === 0) { - this._graphqlQueryResponses.set(q.query, [cannedResponse]); - } else { - cannedResponses.push(cannedResponse); - } - } - - expectGraphQLMutation(m: MutationOptions, result: FetchResult) { - const cannedResponse: RecordedMutationResult = { variables: m.variables, result }; - - const cannedResponses = this._graphqlMutationResponses.get(m.mutation) || []; - if (cannedResponses.length === 0) { - this._graphqlMutationResponses.set(m.mutation, [cannedResponse]); - } else { - cannedResponses.push(cannedResponse); - } - } - - expectOctokitRequest(accessorPath: string[], args: any[], response: R) { - let currentStub: SinonStubbedInstance = this._octokit; - accessorPath.forEach((accessor, i) => { - let nextStub = currentStub[accessor]; - if (nextStub === undefined) { - nextStub = - i < accessorPath.length - 1 - ? {} - : this._sinon.stub().callsFake((...variables) => { - throw new Error( - `Unexpected octokit query: ${accessorPath.join('.')}(${variables - .map(v => inspect(v)) - .join(', ')})`, - ); - }); - currentStub[accessor] = nextStub; - } - currentStub = nextStub; - }); - currentStub.withArgs(...args).resolves({ data: response }); - } - - emulateGraphQLQuery(q: QueryOptions): ApolloQueryResult { - const cannedResponses = this._graphqlQueryResponses.get(q.query) || []; - const cannedResponse = cannedResponses.find( - each => - !!each.variables && - Object.keys(each.variables).every(key => each.variables![key] === q.variables![key]), - ); - if (cannedResponse) { - return cannedResponse.result; - } else { - if (cannedResponses.length > 0) { - let message = 'Variables did not match any expected queries:\n'; - for (const { variables } of cannedResponses) { - message += ` ${inspect(variables, { depth: 3 })}\n`; - } - console.error(message); - } - throw new Error(`Unexpected GraphQL query: ${q}`); - } - } - - emulateGraphQLMutation(m: MutationOptions): FetchResult { - const cannedResponses = this._graphqlMutationResponses.get(m.mutation) || []; - const cannedResponse = cannedResponses.find( - each => - !!each.variables && - Object.keys(each.variables).every(key => equals(each.variables![key], m.variables![key])), - ); - if (cannedResponse) { - return cannedResponse.result; - } else { - if (cannedResponses.length > 0) { - let message = 'Variables did not match any expected queries:\n'; - for (const { variables } of cannedResponses) { - message += ` ${inspect(variables, { depth: 3 })}\n`; - } - console.error(message); - } - throw new Error(`Unexpected GraphQL mutation: ${m}`); - } - } -} +import { inspect } from 'util'; +import { Octokit } from '@octokit/rest'; +import { + ApolloQueryResult, + QueryOptions, + + DocumentNode, + OperationVariables, + MutationOptions, + FetchResult, +} from 'apollo-boost'; +import { SinonSandbox, SinonStubbedInstance } from 'sinon'; +import equals from 'fast-deep-equal'; + +interface RecordedQueryResult { + variables?: OperationVariables; + result: ApolloQueryResult; +} + +interface RecordedMutationResult { + variables?: OperationVariables; + result: FetchResult; +} + +export class QueryProvider { + private _graphqlQueryResponses: Map[]>; + private _graphqlMutationResponses: Map[]>; + private _octokit: SinonStubbedInstance; + + constructor(private _sinon: SinonSandbox) { + this._graphqlQueryResponses = new Map(); + this._graphqlMutationResponses = new Map(); + + // Create the stubbed Octokit instance indirectly like this, rather than using `this._sinon.createStubbedInstance()`, + // because the exported Octokit function is actually a bound constructor method. `Object.getPrototypeOf(Octokit)` returns + // the correct prototype, but `Octokit.prototype` does not. + this._octokit = this._sinon.stub(Object.create(Object.getPrototypeOf(Octokit))); + } + + get octokit(): Octokit { + // Cast through "any" because SinonStubbedInstance does not properly map the type of the + // overloaded "authenticate" method. + return (this._octokit as any) as Octokit; + } + + expectGraphQLQuery(q: QueryOptions, result: ApolloQueryResult) { + if (!q.query) { + throw new Error('Empty GraphQL query used in expectation. Is the GraphQL loader configured properly?'); + } + + const cannedResponse: RecordedQueryResult = { variables: q.variables, result }; + + const cannedResponses = this._graphqlQueryResponses.get(q.query) || []; + if (cannedResponses.length === 0) { + this._graphqlQueryResponses.set(q.query, [cannedResponse]); + } else { + cannedResponses.push(cannedResponse); + } + } + + expectGraphQLMutation(m: MutationOptions, result: FetchResult) { + const cannedResponse: RecordedMutationResult = { variables: m.variables, result }; + + const cannedResponses = this._graphqlMutationResponses.get(m.mutation) || []; + if (cannedResponses.length === 0) { + this._graphqlMutationResponses.set(m.mutation, [cannedResponse]); + } else { + cannedResponses.push(cannedResponse); + } + } + + expectOctokitRequest(accessorPath: string[], args: any[], response: R) { + let currentStub: SinonStubbedInstance = this._octokit; + accessorPath.forEach((accessor, i) => { + let nextStub = currentStub[accessor]; + if (nextStub === undefined) { + nextStub = + i < accessorPath.length - 1 + ? {} + : this._sinon.stub().callsFake((...variables) => { + throw new Error( + `Unexpected octokit query: ${accessorPath.join('.')}(${variables + .map(v => inspect(v)) + .join(', ')})`, + ); + }); + currentStub[accessor] = nextStub; + } + currentStub = nextStub; + }); + currentStub.withArgs(...args).resolves({ data: response }); + } + + emulateGraphQLQuery(q: QueryOptions): ApolloQueryResult { + const cannedResponses = this._graphqlQueryResponses.get(q.query) || []; + const cannedResponse = cannedResponses.find( + each => + !!each.variables && + Object.keys(each.variables).every(key => each.variables![key] === q.variables![key]), + ); + if (cannedResponse) { + return cannedResponse.result; + } else { + if (cannedResponses.length > 0) { + let message = 'Variables did not match any expected queries:\n'; + for (const { variables } of cannedResponses) { + message += ` ${inspect(variables, { depth: 3 })}\n`; + } + console.error(message); + } + throw new Error(`Unexpected GraphQL query: ${q}`); + } + } + + emulateGraphQLMutation(m: MutationOptions): FetchResult { + const cannedResponses = this._graphqlMutationResponses.get(m.mutation) || []; + const cannedResponse = cannedResponses.find( + each => + !!each.variables && + Object.keys(each.variables).every(key => equals(each.variables![key], m.variables![key])), + ); + if (cannedResponse) { + return cannedResponse.result; + } else { + if (cannedResponses.length > 0) { + let message = 'Variables did not match any expected queries:\n'; + for (const { variables } of cannedResponses) { + message += ` ${inspect(variables, { depth: 3 })}\n`; + } + console.error(message); + } + throw new Error(`Unexpected GraphQL mutation: ${m}`); + } + } +} diff --git a/src/test/runTests.ts b/src/test/runTests.ts index ed01fcc776..4d1f0050a0 100644 --- a/src/test/runTests.ts +++ b/src/test/runTests.ts @@ -1,25 +1,26 @@ -import * as path from 'path'; -import { runTests } from '@vscode/test-electron'; - -async function go() { - try { - const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); - const extensionTestsPath = path.resolve(__dirname, './'); - console.log(extensionDevelopmentPath, extensionTestsPath); - - /** - * Basic usage - */ - await runTests({ - version: 'insiders', - extensionDevelopmentPath, - extensionTestsPath, - launchArgs: ['--disable-extensions'], - }); - } catch (e) { - console.log(e); - process.exit(1); - } -} - -setTimeout(() => go(), 10000); +import * as path from 'path'; +import { runTests } from '@vscode/test-electron'; + +async function go() { + try { + + const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); + const extensionTestsPath = path.resolve(__dirname, './'); + console.log(extensionDevelopmentPath, extensionTestsPath); + + /** + * Basic usage + */ + await runTests({ + version: 'insiders', + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: ['--disable-extensions'], + }); + } catch (e) { + console.log(e); + process.exit(1); + } +} + +setTimeout(() => go(), 10000); diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index e22fcc5b75..7cebf01d4c 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -1,218 +1,219 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { SinonSandbox, createSandbox } from 'sinon'; -import { default as assert } from 'assert'; -import { Octokit } from '@octokit/rest'; - -import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; - -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { MockRepository } from '../mocks/mockRepository'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { Protocol } from '../../common/protocol'; -import { CredentialStore, GitHub } from '../../github/credentials'; -import { parseGraphQLPullRequest } from '../../github/utils'; -import { Resource } from '../../common/resources'; -import { GitApiImpl } from '../../api/api1'; -import { RepositoriesManager } from '../../github/repositoriesManager'; -import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; -import { GitHubServerType } from '../../common/authentication'; -import { DataUri } from '../../common/uri'; -import { IAccount, ITeam } from '../../github/interface'; - -describe('GitHub Pull Requests view', function () { - let sinon: SinonSandbox; - let context: MockExtensionContext; - let telemetry: MockTelemetry; - let provider: PullRequestsTreeDataProvider; - let credentialStore: CredentialStore; - let reposManager: RepositoriesManager; - - beforeEach(function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - - context = new MockExtensionContext(); - - telemetry = new MockTelemetry(); - reposManager = new RepositoriesManager( - credentialStore, - telemetry, - ); - provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); - credentialStore = new CredentialStore(telemetry, context); - - // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns - // a dummy GitHub/Octokit object. - sinon.stub(credentialStore, 'showSignInNotification').callsFake(async () => { - const github: GitHub = { - octokit: new LoggingOctokit(new Octokit({ - request: {}, - baseUrl: 'https://github.com', - userAgent: 'GitHub VSCode Pull Requests', - previews: ['shadow-cat-preview'], - }), new RateLogger(telemetry, true)), - graphql: null, - }; - - return github; - }); - - Resource.initialize(context); - }); - - afterEach(function () { - provider.dispose(); - context.dispose(); - sinon.restore(); - }); - - it('has no children when no workspace folders are open', async function () { - sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); - - const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 0); - }); - - it('has no children when no GitHub remotes are available', async function () { - sinon - .stub(vscode.workspace, 'workspaceFolders') - .value([{ index: 0, name: __dirname, uri: vscode.Uri.file(__dirname) }]); - - const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 0); - }); - - it('has no children when repositories have not yet been initialized', async function () { - const repository = new MockRepository(); - repository.addRemote('origin', 'git@github.com:aaa/bbb'); - reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); - provider.initialize([], credentialStore); - - const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 0); - }); - - it('opens the viewlet and displays the default categories', async function () { - const repository = new MockRepository(); - repository.addRemote('origin', 'git@github.com:aaa/bbb'); - reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); - sinon.stub(credentialStore, 'isAuthenticated').returns(true); - await reposManager.folderManagers[0].updateRepositories(); - provider.initialize([], credentialStore); - - const rootNodes = await provider.getChildren(); - - // All but the last category are expected to be collapsed - const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); - assert(treeItems.slice(0, treeItems.length - 1).every(n => n.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); - assert(treeItems[treeItems.length - 1].collapsibleState === vscode.TreeItemCollapsibleState.Expanded); - assert.deepStrictEqual( - treeItems.map(n => n.label), - ['Local Pull Request Branches', 'Waiting For My Review', 'Assigned To Me', 'Created By Me', 'All Open'], - ); - }); - - describe('Local Pull Request Branches', function () { - it('creates a node for each local pull request', async function () { - const url = 'git@github.com:aaa/bbb'; - const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); - const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); - gitHubRepository.buildMetadata(m => { - m.clone_url('https://github.com/aaa/bbb'); - }); - - const pr0 = gitHubRepository.addGraphQLPullRequest(builder => { - builder.pullRequest(pr => { - pr.repository(r => - r.pullRequest(p => { - p.number(1111); - p.title('zero'); - p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg').url('https://github.com/me')); - p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); - p.baseRepository(r => r.url('https://github.com/aaa/bbb')); - }), - ); - }); - }).pullRequest; - const prItem0 = parseGraphQLPullRequest(pr0.repository.pullRequest, gitHubRepository); - const pullRequest0 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem0); - - const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { - builder.pullRequest(pr => { - pr.repository(r => - r.pullRequest(p => { - p.number(2222); - p.title('one'); - p.author(a => a.login('you').avatarUrl('https://avatars.com/you.jpg')); - p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); - p.baseRepository(r => r.url('https://github.com/aaa/bbb')); - }), - ); - }); - }).pullRequest; - const prItem1 = parseGraphQLPullRequest(pr1.repository.pullRequest, gitHubRepository); - const pullRequest1 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem1); - - const repository = new MockRepository(); - await repository.addRemote(remote.remoteName, remote.url); - - await repository.createBranch('pr-branch-0', false); - await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest0, 'pr-branch-0'); - await repository.createBranch('pr-branch-1', true); - await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest1, 'pr-branch-1'); - - await repository.createBranch('non-pr-branch', false); - - const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); - reposManager.insertFolderManager(manager); - sinon.stub(manager, 'createGitHubRepository').callsFake((r, cs) => { - assert.deepStrictEqual(r, remote); - assert.strictEqual(cs, credentialStore); - return Promise.resolve(gitHubRepository); - }); - sinon.stub(credentialStore, 'isAuthenticated').returns(true); - sinon.stub(DataUri, 'avatarCirclesAsImageDataUris').callsFake((context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean) => { - return Promise.resolve(users.map(user => user.avatarUrl ? vscode.Uri.parse(user.avatarUrl) : undefined)); - }); - await manager.updateRepositories(); - provider.initialize([], credentialStore); - manager.activePullRequest = pullRequest1; - - const rootNodes = await provider.getChildren(); - const rootTreeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); - const localNode = rootNodes.find((_node, index) => rootTreeItems[index].label === 'Local Pull Request Branches'); - assert(localNode); - - // Need to call getChildren twice to get past the quick render with an empty list - await localNode!.getChildren(); - const localChildren = await localNode!.getChildren(); - assert.strictEqual(localChildren.length, 2); - const [localItem0, localItem1] = await Promise.all(localChildren.map(node => node.getTreeItem())); - - assert.strictEqual(localItem0.label, 'zero'); - assert.strictEqual(localItem0.tooltip, 'zero by @me'); - assert.strictEqual(localItem0.description, 'by @me'); - assert.strictEqual(localItem0.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive'); - assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg'); - - assert.strictEqual(localItem1.label, '✓ one'); - assert.strictEqual(localItem1.tooltip, 'Current Branch * one by @you'); - assert.strictEqual(localItem1.description, 'by @you'); - assert.strictEqual(localItem1.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - assert.strictEqual(localItem1.contextValue, 'pullrequest:local:active'); - assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://avatars.com/you.jpg'); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { SinonSandbox, createSandbox } from 'sinon'; +import { default as assert } from 'assert'; +import { Octokit } from '@octokit/rest'; + +import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; + +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockRepository } from '../mocks/mockRepository'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { Protocol } from '../../common/protocol'; +import { CredentialStore, GitHub } from '../../github/credentials'; +import { parseGraphQLPullRequest } from '../../github/utils'; +import { Resource } from '../../common/resources'; +import { GitApiImpl } from '../../api/api1'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; +import { GitHubServerType } from '../../common/authentication'; +import { DataUri } from '../../common/uri'; +import { IAccount, ITeam } from '../../github/interface'; + +describe('GitHub Pull Requests view', function () { + let sinon: SinonSandbox; + let context: MockExtensionContext; + let telemetry: MockTelemetry; + let provider: PullRequestsTreeDataProvider; + let credentialStore: CredentialStore; + let reposManager: RepositoriesManager; + + beforeEach(function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + + context = new MockExtensionContext(); + + telemetry = new MockTelemetry(); + reposManager = new RepositoriesManager( + credentialStore, + telemetry, + ); + provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + credentialStore = new CredentialStore(telemetry, context); + + // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns + // a dummy GitHub/Octokit object. + sinon.stub(credentialStore, 'showSignInNotification').callsFake(async () => { + const github: GitHub = { + octokit: new LoggingOctokit(new Octokit({ + request: {}, + baseUrl: 'https://github.com', + userAgent: 'GitHub VSCode Pull Requests', + previews: ['shadow-cat-preview'], + }), new RateLogger(telemetry, true)), + graphql: null, + }; + + return github; + }); + + Resource.initialize(context); + }); + + afterEach(function () { + provider.dispose(); + context.dispose(); + sinon.restore(); + }); + + it('has no children when no workspace folders are open', async function () { + sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + + const rootNodes = await provider.getChildren(); + assert.strictEqual(rootNodes.length, 0); + }); + + it('has no children when no GitHub remotes are available', async function () { + sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ index: 0, name: __dirname, uri: vscode.Uri.file(__dirname) }]); + + const rootNodes = await provider.getChildren(); + assert.strictEqual(rootNodes.length, 0); + }); + + it('has no children when repositories have not yet been initialized', async function () { + const repository = new MockRepository(); + repository.addRemote('origin', 'git@github.com:aaa/bbb'); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); + provider.initialize([], credentialStore); + + const rootNodes = await provider.getChildren(); + assert.strictEqual(rootNodes.length, 0); + }); + + it('opens the viewlet and displays the default categories', async function () { + const repository = new MockRepository(); + repository.addRemote('origin', 'git@github.com:aaa/bbb'); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); + sinon.stub(credentialStore, 'isAuthenticated').returns(true); + await reposManager.folderManagers[0].updateRepositories(); + provider.initialize([], credentialStore); + + const rootNodes = await provider.getChildren(); + + // All but the last category are expected to be collapsed + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + assert(treeItems.slice(0, treeItems.length - 1).every(n => n.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); + assert(treeItems[treeItems.length - 1].collapsibleState === vscode.TreeItemCollapsibleState.Expanded); + assert.deepStrictEqual( + treeItems.map(n => n.label), + ['Local Pull Request Branches', 'Waiting For My Review', 'Assigned To Me', 'Created By Me', 'All Open'], + ); + }); + + describe('Local Pull Request Branches', function () { + it('creates a node for each local pull request', async function () { + const url = 'git@github.com:aaa/bbb'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + gitHubRepository.buildMetadata(m => { + m.clone_url('https://github.com/aaa/bbb'); + }); + + const pr0 = gitHubRepository.addGraphQLPullRequest(builder => { + builder.pullRequest(pr => { + pr.repository(r => + r.pullRequest(p => { + p.number(1111); + p.title('zero'); + p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg').url('https://github.com/me')); + p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); + p.baseRepository(r => r.url('https://github.com/aaa/bbb')); + }), + ); + }); + }).pullRequest; + const prItem0 = parseGraphQLPullRequest(pr0.repository.pullRequest, gitHubRepository); + const pullRequest0 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem0); + + const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { + builder.pullRequest(pr => { + pr.repository(r => + r.pullRequest(p => { + p.number(2222); + p.title('one'); + p.author(a => a.login('you').avatarUrl('https://avatars.com/you.jpg')); + p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); + p.baseRepository(r => r.url('https://github.com/aaa/bbb')); + }), + ); + }); + }).pullRequest; + const prItem1 = parseGraphQLPullRequest(pr1.repository.pullRequest, gitHubRepository); + const pullRequest1 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem1); + + const repository = new MockRepository(); + await repository.addRemote(remote.remoteName, remote.url); + + await repository.createBranch('pr-branch-0', false); + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest0, 'pr-branch-0'); + await repository.createBranch('pr-branch-1', true); + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest1, 'pr-branch-1'); + + await repository.createBranch('non-pr-branch', false); + + const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + reposManager.insertFolderManager(manager); + sinon.stub(manager, 'createGitHubRepository').callsFake((r, cs) => { + assert.deepStrictEqual(r, remote); + assert.strictEqual(cs, credentialStore); + return Promise.resolve(gitHubRepository); + }); + sinon.stub(credentialStore, 'isAuthenticated').returns(true); + sinon.stub(DataUri, 'avatarCirclesAsImageDataUris').callsFake((context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean) => { + return Promise.resolve(users.map(user => user.avatarUrl ? vscode.Uri.parse(user.avatarUrl) : undefined)); + }); + await manager.updateRepositories(); + provider.initialize([], credentialStore); + manager.activePullRequest = pullRequest1; + + const rootNodes = await provider.getChildren(); + const rootTreeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + const localNode = rootNodes.find((_node, index) => rootTreeItems[index].label === 'Local Pull Request Branches'); + assert(localNode); + + // Need to call getChildren twice to get past the quick render with an empty list + await localNode!.getChildren(); + const localChildren = await localNode!.getChildren(); + assert.strictEqual(localChildren.length, 2); + const [localItem0, localItem1] = await Promise.all(localChildren.map(node => node.getTreeItem())); + + assert.strictEqual(localItem0.label, 'zero'); + assert.strictEqual(localItem0.tooltip, 'zero by @me'); + assert.strictEqual(localItem0.description, 'by @me'); + assert.strictEqual(localItem0.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); + assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive'); + assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg'); + + assert.strictEqual(localItem1.label, '✓ one'); + assert.strictEqual(localItem1.tooltip, 'Current Branch * one by @you'); + assert.strictEqual(localItem1.description, 'by @you'); + assert.strictEqual(localItem1.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); + assert.strictEqual(localItem1.contextValue, 'pullrequest:local:active'); + assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://avatars.com/you.jpg'); + }); + }); +}); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index dc5b86dcb9..3d42d49cef 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -1,327 +1,328 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { default as assert } from 'assert'; -import { SinonSandbox, createSandbox } from 'sinon'; -import { CredentialStore } from '../../github/credentials'; -import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { MockTelemetry } from '../mocks/mockTelemetry'; -import { ReviewCommentController } from '../../view/reviewCommentController'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { MockRepository } from '../mocks/mockRepository'; -import { GitFileChangeNode } from '../../view/treeNodes/fileChangeNode'; -import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; -import { GitChangeType } from '../../common/file'; -import { toReviewUri } from '../../common/uri'; -import * as vscode from 'vscode'; -import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { Protocol } from '../../common/protocol'; -import { GitHubRemote, Remote } from '../../common/remote'; -import { GHPRCommentThread } from '../../github/prComment'; -import { DiffLine } from '../../common/diffHunk'; -import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { GitApiImpl } from '../../api/api1'; -import { DiffSide, SubjectType } from '../../common/comment'; -import { ReviewManager, ShowPullRequest } from '../../view/reviewManager'; -import { PullRequestChangesTreeDataProvider } from '../../view/prChangesTreeDataProvider'; -import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { ReviewModel } from '../../view/reviewModel'; -import { Resource } from '../../common/resources'; -import { RepositoriesManager } from '../../github/repositoriesManager'; -import { GitFileChangeModel } from '../../view/fileChangeModel'; -import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; -import { GitHubServerType } from '../../common/authentication'; -import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; -import { mergeQuerySchemaWithShared } from '../../github/common'; -const schema = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; - -const protocol = new Protocol('https://github.com/github/test.git'); -const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); - -class TestReviewCommentController extends ReviewCommentController { - public workspaceFileChangeCommentThreads() { - return this._workspaceFileChangeCommentThreads; - } -} - -describe('ReviewCommentController', function () { - let sinon: SinonSandbox; - let credentialStore: CredentialStore; - let repository: MockRepository; - let telemetry: MockTelemetry; - let provider: PullRequestsTreeDataProvider; - let manager: FolderRepositoryManager; - let activePullRequest: PullRequestModel; - let githubRepo: MockGitHubRepository; - let reviewManager: ReviewManager; - let reposManager: RepositoriesManager; - - beforeEach(async function () { - sinon = createSandbox(); - MockCommandRegistry.install(sinon); - - telemetry = new MockTelemetry(); - const context = new MockExtensionContext(); - credentialStore = new CredentialStore(telemetry, context); - - repository = new MockRepository(); - repository.addRemote('origin', 'git@github.com:aaa/bbb'); - reposManager = new RepositoriesManager(credentialStore, telemetry); - provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); - const activePrViewCoordinator = new WebviewViewCoordinator(context); - const createPrHelper = new CreatePullRequestHelper(); - Resource.initialize(context); - const gitApiImpl = new GitApiImpl(); - manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore); - reposManager.insertFolderManager(manager); - const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, reposManager); - reviewManager = new ReviewManager(0, context, repository, manager, telemetry, tree, provider, new ShowPullRequest(), activePrViewCoordinator, createPrHelper, gitApiImpl); - sinon.stub(manager, 'createGitHubRepository').callsFake((r, cStore) => { - return Promise.resolve(new MockGitHubRepository(GitHubRemote.remoteAsGitHub(r, GitHubServerType.GitHubDotCom), cStore, telemetry, sinon)); - }); - sinon.stub(credentialStore, 'isAuthenticated').returns(false); - await manager.updateRepositories(); - - const pr = new PullRequestBuilder().build(); - githubRepo = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); - activePullRequest = new PullRequestModel( - credentialStore, - telemetry, - githubRepo, - remote, - convertRESTPullRequestToRawPullRequest(pr, githubRepo), - ); - - manager.activePullRequest = activePullRequest; - }); - - afterEach(function () { - sinon.restore(); - }); - - function createLocalFileChange(uri: vscode.Uri, fileName: string, rootUri: vscode.Uri): GitFileChangeNode { - const gitFileChangeModel = new GitFileChangeModel( - manager, - activePullRequest, - { - status: GitChangeType.MODIFY, - fileName, - blobUrl: 'https://example.com', - diffHunks: - [ - { - oldLineNumber: 22, - oldLength: 5, - newLineNumber: 22, - newLength: 11, - positionInHunk: 0, - diffLines: [ - new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), - new DiffLine(0, 22, 22, 1, " 'title': 'Papayas',", true), - new DiffLine(0, 23, 23, 2, " 'title': 'Papayas',", true), - new DiffLine(0, 24, 24, 3, " 'title': 'Papayas',", true), - new DiffLine(1, -1, 25, 4, '+ {', true), - new DiffLine(1, -1, 26, 5, '+ {', true), - new DiffLine(1, -1, 27, 6, '+ {', true), - new DiffLine(1, -1, 28, 7, '+ {', true), - new DiffLine(1, -1, 29, 8, '+ {', true), - new DiffLine(1, -1, 30, 9, '+ {', true), - new DiffLine(0, 25, 31, 10, '+ {', true), - new DiffLine(0, 26, 32, 11, '+ {', true), - ], - }, - ] - }, - uri, - toReviewUri(uri, fileName, undefined, '1', false, { base: true }, rootUri), - 'abcd' - ); - - return new GitFileChangeNode( - provider, - manager, - activePullRequest, - gitFileChangeModel - ); - } - - function createGHPRCommentThread(threadId: string, uri: vscode.Uri): GHPRCommentThread { - return { - gitHubThreadId: threadId, - uri, - range: new vscode.Range(new vscode.Position(21, 0), new vscode.Position(21, 0)), - comments: [], - collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, - label: 'Start discussion', - state: vscode.CommentThreadState.Unresolved, - canReply: false, - dispose: () => { }, - }; - } - - describe('initializes workspace thread data', async function () { - const fileName = 'data/products.json'; - const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); - const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; - const reviewModel = new ReviewModel(); - reviewModel.localFileChanges = localFileChanges; - const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel); - - sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); - sinon.stub(activePullRequest, 'getReviewThreads').returns( - Promise.resolve([ - { - id: '1', - isResolved: false, - viewerCanResolve: false, - viewerCanUnresolve: false, - path: fileName, - diffSide: DiffSide.RIGHT, - startLine: 372, - endLine: 372, - originalStartLine: 372, - originalEndLine: 372, - isOutdated: false, - comments: [ - { - id: 1, - url: '', - diffHunk: '', - body: '', - createdAt: '', - htmlUrl: '', - graphNodeId: '', - } - ], - subjectType: SubjectType.LINE - }, - ]), - ); - - sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ - login: 'rmacfarlane', - url: 'https://github.com/rmacfarlane', - id: '123' - })); - - sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ - uri: repository.rootUri, - name: '', - index: 0, - }); - - await reviewCommentController.initialize(); - const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); - assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); - }); - - describe('createOrReplyComment', function () { - it('creates a new comment on an empty thread in a local file', async function () { - const fileName = 'data/products.json'; - const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); - await activePullRequest.initializeReviewThreadCache(); - const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; - const reviewModel = new ReviewModel(); - reviewModel.localFileChanges = localFileChanges; - const reviewCommentController = new TestReviewCommentController( - reviewManager, - manager, - repository, - reviewModel, - ); - const thread = createGHPRCommentThread('review-1.1', uri); - - sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); - sinon.stub(activePullRequest, 'getReviewThreads').returns(Promise.resolve([])); - sinon.stub(activePullRequest, 'getPendingReviewId').returns(Promise.resolve(undefined)); - - sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ - login: 'rmacfarlane', - url: 'https://github.com/rmacfarlane', - id: '123' - })); - - sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ - uri: repository.rootUri, - name: '', - index: 0, - }); - - sinon.stub(vscode.workspace, 'asRelativePath').callsFake((pathOrUri: string | vscode.Uri): string => { - const path = pathOrUri.toString(); - return path.substring('/root/'.length); - }); - - sinon.stub(repository, 'diffWith').returns(Promise.resolve('')); - - await reviewCommentController.initialize(); - const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 0); - - githubRepo.queryProvider.expectGraphQLMutation( - { - mutation: schema.AddReviewThread, - variables: { - input: { - path: fileName, - body: 'hello world', - pullRequestId: activePullRequest.graphNodeId, - pullRequestReviewId: undefined, - startLine: undefined, - line: 22, - side: 'RIGHT', - subjectType: 'LINE' - } - } - }, - { - data: { - addPullRequestReviewThread: { - thread: { - id: 1, - isResolved: false, - viewCanResolve: true, - path: fileName, - line: 22, - startLine: null, - originalStartLine: null, - originalLine: 22, - diffSide: 'RIGHT', - isOutdated: false, - subjectType: 'LINE', - comments: { - nodes: [ - { - databaseId: 1, - id: 1, - body: 'hello world', - commit: {}, - diffHunk: '', - reactionGroups: [], - author: {} - } - ] - } - } - } - } - } - ) - - await reviewCommentController.createOrReplyComment(thread, 'hello world', false); - - assert.strictEqual(thread.comments.length, 1); - assert.strictEqual(thread.comments[0].parent, thread); - - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); - assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); - }); - }); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { default as assert } from 'assert'; +import { SinonSandbox, createSandbox } from 'sinon'; +import { CredentialStore } from '../../github/credentials'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { ReviewCommentController } from '../../view/reviewCommentController'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { MockRepository } from '../mocks/mockRepository'; +import { GitFileChangeNode } from '../../view/treeNodes/fileChangeNode'; +import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; +import { GitChangeType } from '../../common/file'; +import { toReviewUri } from '../../common/uri'; +import * as vscode from 'vscode'; +import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; +import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { Protocol } from '../../common/protocol'; +import { GitHubRemote, Remote } from '../../common/remote'; +import { GHPRCommentThread } from '../../github/prComment'; +import { DiffLine } from '../../common/diffHunk'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { GitApiImpl } from '../../api/api1'; +import { DiffSide, SubjectType } from '../../common/comment'; +import { ReviewManager, ShowPullRequest } from '../../view/reviewManager'; +import { PullRequestChangesTreeDataProvider } from '../../view/prChangesTreeDataProvider'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { ReviewModel } from '../../view/reviewModel'; +import { Resource } from '../../common/resources'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { GitFileChangeModel } from '../../view/fileChangeModel'; +import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; +import { GitHubServerType } from '../../common/authentication'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const schema = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; + +const protocol = new Protocol('https://github.com/github/test.git'); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); + +class TestReviewCommentController extends ReviewCommentController { + public workspaceFileChangeCommentThreads() { + return this._workspaceFileChangeCommentThreads; + } +} + +describe('ReviewCommentController', function () { + let sinon: SinonSandbox; + let credentialStore: CredentialStore; + let repository: MockRepository; + let telemetry: MockTelemetry; + let provider: PullRequestsTreeDataProvider; + let manager: FolderRepositoryManager; + let activePullRequest: PullRequestModel; + let githubRepo: MockGitHubRepository; + let reviewManager: ReviewManager; + let reposManager: RepositoriesManager; + + beforeEach(async function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + + telemetry = new MockTelemetry(); + const context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); + + repository = new MockRepository(); + repository.addRemote('origin', 'git@github.com:aaa/bbb'); + reposManager = new RepositoriesManager(credentialStore, telemetry); + provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + const activePrViewCoordinator = new WebviewViewCoordinator(context); + const createPrHelper = new CreatePullRequestHelper(); + Resource.initialize(context); + const gitApiImpl = new GitApiImpl(); + manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore); + reposManager.insertFolderManager(manager); + const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, reposManager); + reviewManager = new ReviewManager(0, context, repository, manager, telemetry, tree, provider, new ShowPullRequest(), activePrViewCoordinator, createPrHelper, gitApiImpl); + sinon.stub(manager, 'createGitHubRepository').callsFake((r, cStore) => { + return Promise.resolve(new MockGitHubRepository(GitHubRemote.remoteAsGitHub(r, GitHubServerType.GitHubDotCom), cStore, telemetry, sinon)); + }); + sinon.stub(credentialStore, 'isAuthenticated').returns(false); + await manager.updateRepositories(); + + const pr = new PullRequestBuilder().build(); + githubRepo = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + activePullRequest = new PullRequestModel( + credentialStore, + telemetry, + githubRepo, + remote, + convertRESTPullRequestToRawPullRequest(pr, githubRepo), + ); + + manager.activePullRequest = activePullRequest; + }); + + afterEach(function () { + sinon.restore(); + }); + + function createLocalFileChange(uri: vscode.Uri, fileName: string, rootUri: vscode.Uri): GitFileChangeNode { + const gitFileChangeModel = new GitFileChangeModel( + manager, + activePullRequest, + { + status: GitChangeType.MODIFY, + fileName, + blobUrl: 'https://example.com', + diffHunks: + [ + { + oldLineNumber: 22, + oldLength: 5, + newLineNumber: 22, + newLength: 11, + positionInHunk: 0, + diffLines: [ + new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), + new DiffLine(0, 22, 22, 1, " 'title': 'Papayas',", true), + new DiffLine(0, 23, 23, 2, " 'title': 'Papayas',", true), + new DiffLine(0, 24, 24, 3, " 'title': 'Papayas',", true), + new DiffLine(1, -1, 25, 4, '+ {', true), + new DiffLine(1, -1, 26, 5, '+ {', true), + new DiffLine(1, -1, 27, 6, '+ {', true), + new DiffLine(1, -1, 28, 7, '+ {', true), + new DiffLine(1, -1, 29, 8, '+ {', true), + new DiffLine(1, -1, 30, 9, '+ {', true), + new DiffLine(0, 25, 31, 10, '+ {', true), + new DiffLine(0, 26, 32, 11, '+ {', true), + ], + }, + ] + }, + uri, + toReviewUri(uri, fileName, undefined, '1', false, { base: true }, rootUri), + 'abcd' + ); + + return new GitFileChangeNode( + provider, + manager, + activePullRequest, + gitFileChangeModel + ); + } + + function createGHPRCommentThread(threadId: string, uri: vscode.Uri): GHPRCommentThread { + return { + gitHubThreadId: threadId, + uri, + range: new vscode.Range(new vscode.Position(21, 0), new vscode.Position(21, 0)), + comments: [], + collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, + label: 'Start discussion', + state: vscode.CommentThreadState.Unresolved, + canReply: false, + dispose: () => { }, + }; + } + + describe('initializes workspace thread data', async function () { + const fileName = 'data/products.json'; + const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); + const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; + const reviewModel = new ReviewModel(); + reviewModel.localFileChanges = localFileChanges; + const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel); + + sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); + sinon.stub(activePullRequest, 'getReviewThreads').returns( + Promise.resolve([ + { + id: '1', + isResolved: false, + viewerCanResolve: false, + viewerCanUnresolve: false, + path: fileName, + diffSide: DiffSide.RIGHT, + startLine: 372, + endLine: 372, + originalStartLine: 372, + originalEndLine: 372, + isOutdated: false, + comments: [ + { + id: 1, + url: '', + diffHunk: '', + body: '', + createdAt: '', + htmlUrl: '', + graphNodeId: '', + } + ], + subjectType: SubjectType.LINE + }, + ]), + ); + + sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ + login: 'rmacfarlane', + url: 'https://github.com/rmacfarlane', + id: '123' + })); + + sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ + uri: repository.rootUri, + name: '', + index: 0, + }); + + await reviewCommentController.initialize(); + const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); + assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); + }); + + describe('createOrReplyComment', function () { + it('creates a new comment on an empty thread in a local file', async function () { + const fileName = 'data/products.json'; + const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); + await activePullRequest.initializeReviewThreadCache(); + const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; + const reviewModel = new ReviewModel(); + reviewModel.localFileChanges = localFileChanges; + const reviewCommentController = new TestReviewCommentController( + reviewManager, + manager, + repository, + reviewModel, + ); + const thread = createGHPRCommentThread('review-1.1', uri); + + sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); + sinon.stub(activePullRequest, 'getReviewThreads').returns(Promise.resolve([])); + sinon.stub(activePullRequest, 'getPendingReviewId').returns(Promise.resolve(undefined)); + + sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ + login: 'rmacfarlane', + url: 'https://github.com/rmacfarlane', + id: '123' + })); + + sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ + uri: repository.rootUri, + name: '', + index: 0, + }); + + sinon.stub(vscode.workspace, 'asRelativePath').callsFake((pathOrUri: string | vscode.Uri): string => { + const path = pathOrUri.toString(); + return path.substring('/root/'.length); + }); + + sinon.stub(repository, 'diffWith').returns(Promise.resolve('')); + + await reviewCommentController.initialize(); + const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 0); + + githubRepo.queryProvider.expectGraphQLMutation( + { + mutation: schema.AddReviewThread, + variables: { + input: { + path: fileName, + body: 'hello world', + pullRequestId: activePullRequest.graphNodeId, + pullRequestReviewId: undefined, + startLine: undefined, + line: 22, + side: 'RIGHT', + subjectType: 'LINE' + } + } + }, + { + data: { + addPullRequestReviewThread: { + thread: { + id: 1, + isResolved: false, + viewCanResolve: true, + path: fileName, + line: 22, + startLine: null, + originalStartLine: null, + originalLine: 22, + diffSide: 'RIGHT', + isOutdated: false, + subjectType: 'LINE', + comments: { + nodes: [ + { + databaseId: 1, + id: 1, + body: 'hello world', + commit: {}, + diffHunk: '', + reactionGroups: [], + author: {} + } + ] + } + } + } + } + } + ) + + await reviewCommentController.createOrReplyComment(thread, 'hello world', false); + + assert.strictEqual(thread.comments.length, 1); + assert.strictEqual(thread.comments[0].parent, thread); + + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); + assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); + }); + }); +}); diff --git a/src/view/compareChangesTreeDataProvider.ts b/src/view/compareChangesTreeDataProvider.ts index de13f45b1d..218b45b6e4 100644 --- a/src/view/compareChangesTreeDataProvider.ts +++ b/src/view/compareChangesTreeDataProvider.ts @@ -1,422 +1,423 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as pathLib from 'path'; -import * as vscode from 'vscode'; -import { Change, Commit } from '../api/api'; -import { Status } from '../api/api1'; -import { getGitChangeType } from '../common/diffHunk'; -import { GitChangeType } from '../common/file'; -import Logger from '../common/logger'; -import { Schemes } from '../common/uri'; -import { dateFromNow, toDisposable } from '../common/utils'; -import { OctokitCommon } from '../github/common'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { CreatePullRequestDataModel } from './createPullRequestDataModel'; -import { GitContentProvider, GitHubContentProvider } from './gitHubContentProvider'; -import { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; -import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; - -export function getGitChangeTypeFromApi(status: Status): GitChangeType { - switch (status) { - case Status.DELETED: - return GitChangeType.DELETE; - case Status.ADDED_BY_US: - return GitChangeType.ADD; - case Status.INDEX_RENAMED: - return GitChangeType.RENAME; - case Status.MODIFIED: - return GitChangeType.MODIFY; - default: - return GitChangeType.UNKNOWN; - } -} - -class GitHubCommitNode extends TreeNode { - getTreeItem(): vscode.TreeItem | Promise { - return { - label: this.commit.commit.message, - description: this.commit.commit.author?.date ? dateFromNow(new Date(this.commit.commit.author.date)) : undefined, - iconPath: new vscode.ThemeIcon('git-commit'), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed - }; - } - - async getChildren(): Promise { - if (!this.model.gitHubRepository) { - return []; - } - const { octokit, remote } = await this.model.gitHubRepository.ensure(); - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: this.parentRef, - head: this.commit.sha, - }); - - const rawFiles = data.files; - - if (!rawFiles) { - return []; - } - return rawFiles.map(file => { - return new GitHubFileChangeNode( - this, - file.filename, - file.previous_filename, - getGitChangeType(file.status), - this.parentRef, - this.commit.sha, - false, - ); - }); - } - - constructor(private readonly model: CreatePullRequestDataModel, private readonly commit: OctokitCommon.CompareCommits['commits'][0], private readonly parentRef) { - super(); - } -} - -class GitCommitNode extends TreeNode { - getTreeItem(): vscode.TreeItem | Promise { - return { - label: this.commit.message, - description: this.commit.authorDate ? dateFromNow(new Date(this.commit.authorDate)) : undefined, - iconPath: new vscode.ThemeIcon('git-commit'), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed - }; - } - - async getChildren(): Promise { - const changes = await this.folderRepoManager.repository.diffBetween(this.parentRef, this.commit.hash); - - return changes.map(change => { - const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); - const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); - return new GitHubFileChangeNode( - this, - filename, - previousFilename, - getGitChangeTypeFromApi(change.status), - this.parentRef, - this.commit.hash, - true, - ); - }); - } - - constructor(private readonly commit: Commit, private readonly folderRepoManager: FolderRepositoryManager, private readonly parentRef) { - super(); - } -} - -abstract class CompareChangesTreeProvider implements vscode.TreeDataProvider, BaseTreeNode { - private _view: vscode.TreeView; - private _children: TreeNode[] | undefined; - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private _disposables: vscode.Disposable[] = []; - - get view(): vscode.TreeView { - return this._view; - } - - set view(view: vscode.TreeView) { - this._view = view; - } - - constructor( - protected readonly model: CreatePullRequestDataModel - ) { - this._disposables.push(model.onDidChange(() => { - this._onDidChangeTreeData.fire(); - })); - } - - async reveal(treeNode: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean }): Promise { - return this._view.reveal(treeNode, options); - } - - refresh(): void { - this._onDidChangeTreeData.fire(); - } - - getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { - return element.getTreeItem(); - } - - protected async getRawGitHubData() { - try { - const rawFiles = await this.model.gitHubFiles(); - const rawCommits = await this.model.gitHubCommits(); - const mergeBase = await this.model.gitHubMergeBase(); - - if (!rawFiles?.length || !rawCommits?.length) { - (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); - return {}; - } else if (this._isDisposed) { - return {}; - } else { - this.view.message = undefined; - } - - return { rawFiles, rawCommits, mergeBase }; - } catch (e) { - if ('name' in e && e.name === 'HttpError' && e.status === 404) { - (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('The upstream branch `{0}` does not exist on GitHub', this.model.baseBranch)); - } - return {}; - } - } - - protected abstract getGitHubChildren(element?: TreeNode): Promise; - - protected abstract getGitChildren(element?: TreeNode): Promise; - - get children(): TreeNode[] | undefined { - return this._children; - } - - async getChildren(element?: TreeNode) { - try { - if (await this.model.getCompareHasUpstream()) { - this._children = await this.getGitHubChildren(element); - } else { - this._children = await this.getGitChildren(element); - } - } catch (e) { - Logger.error(`Comparing changes failed: ${e}`); - return []; - } - return this._children; - } - - protected _isDisposed: boolean = false; - dispose() { - this._isDisposed = true; - this._disposables.forEach(d => d.dispose()); - this._view.dispose(); - } - - public static closeTabs() { - vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { - if (tab.input instanceof vscode.TabInputTextDiff) { - if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { - vscode.window.tabGroups.close(tab); - } - } - })); - } -} - -class CompareChangesFilesTreeProvider extends CompareChangesTreeProvider { - constructor( - model: CreatePullRequestDataModel, - private folderRepoManager: FolderRepositoryManager, - ) { - super(model); - } - - protected async getGitHubChildren(element?: TreeNode) { - if (element) { - return element.getChildren(); - } - - const { rawFiles, mergeBase } = await this.getRawGitHubData(); - if (rawFiles && mergeBase) { - return rawFiles.map(file => { - return new GitHubFileChangeNode( - this, - file.filename, - file.previous_filename, - getGitChangeType(file.status), - mergeBase, - this.model.getCompareBranch(), - false, - ); - }); - } - } - - private async getGitFileChildren(diff: Change[]) { - return diff.map(change => { - const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); - const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); - return new GitHubFileChangeNode( - this, - filename, - previousFilename, - getGitChangeTypeFromApi(change.status), - this.model.baseBranch, - this.model.getCompareBranch(), - true, - ); - }); - } - - protected async getGitChildren(element?: TreeNode) { - if (!element) { - const diff = await this.model.gitFiles(); - if (diff.length === 0) { - (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); - return []; - } else if (!(await this.model.getCompareHasUpstream())) { - const message = new vscode.MarkdownString(vscode.l10n.t({ message: 'Branch `{0}` has not been pushed yet. [Publish branch](command:git.publish) to see all changes.', args: [this.model.getCompareBranch()], comment: "{Locked='](command:git.publish)'}" })); - message.isTrusted = { enabledCommands: ['git.publish'] }; - (this.view as vscode.TreeView2).message = message; - } else if (this._isDisposed) { - return []; - } else { - this.view.message = undefined; - } - - return this.getGitFileChildren(diff); - } else { - return element.getChildren(); - } - - } -} - -class CompareChangesCommitsTreeProvider extends CompareChangesTreeProvider { - constructor( - model: CreatePullRequestDataModel, - private readonly folderRepoManager: FolderRepositoryManager - ) { - super(model); - } - - protected async getGitHubChildren(element?: TreeNode) { - if (element) { - return element.getChildren(); - } - - const { rawCommits } = await this.getRawGitHubData(); - if (rawCommits) { - return rawCommits.map((commit, index) => { - return new GitHubCommitNode(this.model, commit, index === 0 ? this.model.baseBranch : rawCommits[index - 1].sha); - }); - } - } - - protected async getGitChildren(element?: TreeNode) { - if (element) { - return element.getChildren(); - } - - const log = await this.model.gitCommits(); - if (log.length === 0) { - (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); - return []; - } else if (this._isDisposed) { - return []; - } else { - this.view.message = undefined; - } - - return log.reverse().map((commit, index) => { - return new GitCommitNode(commit, this.folderRepoManager, index === 0 ? this.model.baseBranch : log[index - 1].hash); - }); - } -} - -export class CompareChanges implements vscode.Disposable { - private _filesView: vscode.TreeView; - private _filesDataProvider: CompareChangesFilesTreeProvider; - private _commitsView: vscode.TreeView; - private _commitsDataProvider: CompareChangesCommitsTreeProvider; - - private _gitHubcontentProvider: GitHubContentProvider | undefined; - private _gitcontentProvider: GitContentProvider | undefined; - - private _disposables: vscode.Disposable[] = []; - - constructor( - private folderRepoManager: FolderRepositoryManager, - private model: CreatePullRequestDataModel - ) { - - this._filesDataProvider = new CompareChangesFilesTreeProvider(model, folderRepoManager); - this._filesView = vscode.window.createTreeView('github:compareChangesFiles', { - treeDataProvider: this._filesDataProvider - }); - this._filesDataProvider.view = this._filesView; - this._commitsDataProvider = new CompareChangesCommitsTreeProvider(model, folderRepoManager); - this._commitsView = vscode.window.createTreeView('github:compareChangesCommits', { - treeDataProvider: this._commitsDataProvider - }); - this._commitsDataProvider.view = this._commitsView; - this._disposables.push(this._filesDataProvider); - this._disposables.push(this._filesView); - this._disposables.push(this._commitsDataProvider); - this._disposables.push(this._commitsView); - - this.initialize(); - } - - updateBaseBranch(branch: string): void { - this.model.baseBranch = branch; - } - - updateBaseOwner(owner: string) { - this.model.baseOwner = owner; - } - - async updateCompareBranch(branch?: string): Promise { - this.model.setCompareBranch(branch); - } - - set compareOwner(owner: string) { - this.model.compareOwner = owner; - } - - private initialize() { - if (!this.model.gitHubRepository) { - return; - } - - if (!this._gitHubcontentProvider) { - try { - this._gitHubcontentProvider = new GitHubContentProvider(this.model.gitHubRepository); - this._gitcontentProvider = new GitContentProvider(this.folderRepoManager); - this._disposables.push( - vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this._gitHubcontentProvider, { - isReadonly: true, - }), - ); - this._disposables.push( - vscode.workspace.registerFileSystemProvider(Schemes.GitPr, this._gitcontentProvider, { - isReadonly: true, - }), - ); - this._disposables.push(toDisposable(() => { - CompareChangesTreeProvider.closeTabs(); - })); - } catch (e) { - // already registered - } - } - } - - dispose() { - this._disposables.forEach(d => d.dispose()); - this._gitHubcontentProvider = undefined; - this._gitcontentProvider = undefined; - this._filesView.dispose(); - } - - public static closeTabs() { - vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { - if (tab.input instanceof vscode.TabInputTextDiff) { - if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { - vscode.window.tabGroups.close(tab); - } - } - })); - } -} - - +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import { Change, Commit } from '../api/api'; +import { Status } from '../api/api1'; +import { getGitChangeType } from '../common/diffHunk'; +import { GitChangeType } from '../common/file'; +import Logger from '../common/logger'; +import { Schemes } from '../common/uri'; +import { dateFromNow, toDisposable } from '../common/utils'; +import { OctokitCommon } from '../github/common'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; +import { GitContentProvider, GitHubContentProvider } from './gitHubContentProvider'; +import { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; +import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; + +export function getGitChangeTypeFromApi(status: Status): GitChangeType { + switch (status) { + case Status.DELETED: + return GitChangeType.DELETE; + case Status.ADDED_BY_US: + return GitChangeType.ADD; + case Status.INDEX_RENAMED: + return GitChangeType.RENAME; + case Status.MODIFIED: + return GitChangeType.MODIFY; + default: + return GitChangeType.UNKNOWN; + } +} + +class GitHubCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise { + return { + label: this.commit.commit.message, + description: this.commit.commit.author?.date ? dateFromNow(new Date(this.commit.commit.author.date)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } + + async getChildren(): Promise { + if (!this.model.gitHubRepository) { + return []; + } + const { octokit, remote } = await this.model.gitHubRepository.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: this.parentRef, + head: this.commit.sha, + }); + + const rawFiles = data.files; + + if (!rawFiles) { + return []; + } + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + this.parentRef, + this.commit.sha, + false, + ); + }); + } + + constructor(private readonly model: CreatePullRequestDataModel, private readonly commit: OctokitCommon.CompareCommits['commits'][0], private readonly parentRef) { + super(); + } +} + +class GitCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise { + return { + label: this.commit.message, + description: this.commit.authorDate ? dateFromNow(new Date(this.commit.authorDate)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } + + async getChildren(): Promise { + const changes = await this.folderRepoManager.repository.diffBetween(this.parentRef, this.commit.hash); + + return changes.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.parentRef, + this.commit.hash, + true, + ); + }); + } + + constructor(private readonly commit: Commit, private readonly folderRepoManager: FolderRepositoryManager, private readonly parentRef) { + super(); + } +} + +abstract class CompareChangesTreeProvider implements vscode.TreeDataProvider, BaseTreeNode { + private _view: vscode.TreeView; + private _children: TreeNode[] | undefined; + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private _disposables: vscode.Disposable[] = []; + + get view(): vscode.TreeView { + return this._view; + } + + set view(view: vscode.TreeView) { + this._view = view; + } + + constructor( + protected readonly model: CreatePullRequestDataModel + ) { + this._disposables.push(model.onDidChange(() => { + this._onDidChangeTreeData.fire(); + })); + } + + async reveal(treeNode: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean }): Promise { + return this._view.reveal(treeNode, options); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { + return element.getTreeItem(); + } + + protected async getRawGitHubData() { + try { + const rawFiles = await this.model.gitHubFiles(); + const rawCommits = await this.model.gitHubCommits(); + const mergeBase = await this.model.gitHubMergeBase(); + + if (!rawFiles?.length || !rawCommits?.length) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return {}; + } else if (this._isDisposed) { + return {}; + } else { + this.view.message = undefined; + } + + return { rawFiles, rawCommits, mergeBase }; + } catch (e) { + if ('name' in e && e.name === 'HttpError' && e.status === 404) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('The upstream branch `{0}` does not exist on GitHub', this.model.baseBranch)); + } + return {}; + } + } + + protected abstract getGitHubChildren(element?: TreeNode): Promise; + + protected abstract getGitChildren(element?: TreeNode): Promise; + + get children(): TreeNode[] | undefined { + return this._children; + } + + async getChildren(element?: TreeNode) { + try { + if (await this.model.getCompareHasUpstream()) { + this._children = await this.getGitHubChildren(element); + } else { + this._children = await this.getGitChildren(element); + } + } catch (e) { + Logger.error(`Comparing changes failed: ${e}`); + return []; + } + return this._children; + } + + protected _isDisposed: boolean = false; + dispose() { + this._isDisposed = true; + this._disposables.forEach(d => d.dispose()); + this._view.dispose(); + } + + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); + } +} + +class CompareChangesFilesTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private folderRepoManager: FolderRepositoryManager, + ) { + super(model); + } + + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + const { rawFiles, mergeBase } = await this.getRawGitHubData(); + if (rawFiles && mergeBase) { + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + mergeBase, + this.model.getCompareBranch(), + false, + ); + }); + } + } + + private async getGitFileChildren(diff: Change[]) { + return diff.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.model.baseBranch, + this.model.getCompareBranch(), + true, + ); + }); + } + + protected async getGitChildren(element?: TreeNode) { + if (!element) { + const diff = await this.model.gitFiles(); + if (diff.length === 0) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return []; + } else if (!(await this.model.getCompareHasUpstream())) { + const message = new vscode.MarkdownString(vscode.l10n.t({ message: 'Branch `{0}` has not been pushed yet. [Publish branch](command:git.publish) to see all changes.', args: [this.model.getCompareBranch()], comment: "{Locked='](command:git.publish)'}" })); + message.isTrusted = { enabledCommands: ['git.publish'] }; + (this.view as vscode.TreeView2).message = message; + } else if (this._isDisposed) { + return []; + } else { + this.view.message = undefined; + } + + return this.getGitFileChildren(diff); + } else { + return element.getChildren(); + } + + } +} + +class CompareChangesCommitsTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private readonly folderRepoManager: FolderRepositoryManager + ) { + super(model); + } + + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + const { rawCommits } = await this.getRawGitHubData(); + if (rawCommits) { + return rawCommits.map((commit, index) => { + return new GitHubCommitNode(this.model, commit, index === 0 ? this.model.baseBranch : rawCommits[index - 1].sha); + }); + } + } + + protected async getGitChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + const log = await this.model.gitCommits(); + if (log.length === 0) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return []; + } else if (this._isDisposed) { + return []; + } else { + this.view.message = undefined; + } + + return log.reverse().map((commit, index) => { + return new GitCommitNode(commit, this.folderRepoManager, index === 0 ? this.model.baseBranch : log[index - 1].hash); + }); + } +} + +export class CompareChanges implements vscode.Disposable { + private _filesView: vscode.TreeView; + private _filesDataProvider: CompareChangesFilesTreeProvider; + private _commitsView: vscode.TreeView; + private _commitsDataProvider: CompareChangesCommitsTreeProvider; + + private _gitHubcontentProvider: GitHubContentProvider | undefined; + private _gitcontentProvider: GitContentProvider | undefined; + + private _disposables: vscode.Disposable[] = []; + + constructor( + private folderRepoManager: FolderRepositoryManager, + private model: CreatePullRequestDataModel + ) { + + this._filesDataProvider = new CompareChangesFilesTreeProvider(model, folderRepoManager); + this._filesView = vscode.window.createTreeView('github:compareChangesFiles', { + treeDataProvider: this._filesDataProvider + }); + this._filesDataProvider.view = this._filesView; + this._commitsDataProvider = new CompareChangesCommitsTreeProvider(model, folderRepoManager); + this._commitsView = vscode.window.createTreeView('github:compareChangesCommits', { + treeDataProvider: this._commitsDataProvider + }); + this._commitsDataProvider.view = this._commitsView; + this._disposables.push(this._filesDataProvider); + this._disposables.push(this._filesView); + this._disposables.push(this._commitsDataProvider); + this._disposables.push(this._commitsView); + + this.initialize(); + } + + updateBaseBranch(branch: string): void { + this.model.baseBranch = branch; + } + + updateBaseOwner(owner: string) { + this.model.baseOwner = owner; + } + + async updateCompareBranch(branch?: string): Promise { + this.model.setCompareBranch(branch); + } + + set compareOwner(owner: string) { + this.model.compareOwner = owner; + } + + private initialize() { + if (!this.model.gitHubRepository) { + return; + } + + if (!this._gitHubcontentProvider) { + try { + this._gitHubcontentProvider = new GitHubContentProvider(this.model.gitHubRepository); + this._gitcontentProvider = new GitContentProvider(this.folderRepoManager); + this._disposables.push( + vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this._gitHubcontentProvider, { + isReadonly: true, + }), + ); + this._disposables.push( + vscode.workspace.registerFileSystemProvider(Schemes.GitPr, this._gitcontentProvider, { + isReadonly: true, + }), + ); + this._disposables.push(toDisposable(() => { + CompareChangesTreeProvider.closeTabs(); + })); + } catch (e) { + // already registered + } + } + } + + dispose() { + this._disposables.forEach(d => d.dispose()); + this._gitHubcontentProvider = undefined; + this._gitcontentProvider = undefined; + this._filesView.dispose(); + } + + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); + } +} + + diff --git a/src/view/createPullRequestDataModel.ts b/src/view/createPullRequestDataModel.ts index d2910eda84..b5e19c6e8f 100644 --- a/src/view/createPullRequestDataModel.ts +++ b/src/view/createPullRequestDataModel.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { Change, Commit } from '../api/api'; import { OctokitCommon } from '../github/common'; @@ -190,4 +191,4 @@ export class CreatePullRequestDataModel { } return this._gitHubMergeBase!; } -} \ No newline at end of file +} diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 7317e8d88a..40b1423815 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -1,241 +1,242 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { ITelemetry } from '../common/telemetry'; -import { dispose } from '../common/utils'; -import { CreatePullRequestViewProviderNew } from '../github/createPRViewProviderNew'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { CompareChanges } from './compareChangesTreeDataProvider'; -import { CreatePullRequestDataModel } from './createPullRequestDataModel'; - -export class CreatePullRequestHelper implements vscode.Disposable { - private _disposables: vscode.Disposable[] = []; - private _createPRViewProvider: CreatePullRequestViewProviderNew | undefined; - private _treeView: CompareChanges | undefined; - private _postCreateCallback: ((pullRequestModel: PullRequestModel) => Promise) | undefined; - - constructor() { } - - private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { - this._disposables.push( - this._createPRViewProvider!.onDone(async createdPR => { - if (createdPR) { - await CreatePullRequestViewProviderNew.withProgress(async () => { - return this._postCreateCallback?.(createdPR); - }); - } - this.dispose(); - }), - ); - - this._disposables.push( - this._createPRViewProvider!.onDidChangeCompareBranch(compareBranch => { - this._treeView?.updateCompareBranch(compareBranch); - }), - ); - - this._disposables.push( - this._createPRViewProvider!.onDidChangeCompareRemote(compareRemote => { - if (this._treeView) { - this._treeView.compareOwner = compareRemote.owner; - } - }), - ); - - this._disposables.push( - this._createPRViewProvider!.onDidChangeBaseBranch(baseBranch => { - this._treeView?.updateBaseBranch(baseBranch); - }), - ); - - this._disposables.push( - this._createPRViewProvider!.onDidChangeBaseRemote(remoteInfo => { - this._treeView?.updateBaseOwner(remoteInfo.owner); - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - return this._createPRViewProvider.addAssignees(); - } - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - return this._createPRViewProvider.addReviewers(); - } - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { - return this._createPRViewProvider?.addLabels(); - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - return this._createPRViewProvider.addMilestone(); - } - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuCreate', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(false, false, undefined); - } - }) - ); - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuDraft', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(true, false, undefined); - } - }) - ); - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(false, true, undefined, true); - } - }) - ); - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuMerge', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(false, true, 'merge'); - } - }) - ); - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuSquash', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(false, true, 'squash'); - } - }) - ); - this._disposables.push( - vscode.commands.registerCommand('pr.createPrMenuRebase', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { - this._createPRViewProvider.createFromCommand(false, true, 'rebase'); - } - }) - ); - - if (usingCurrentBranchAsCompare) { - this._disposables.push( - repository.state.onDidChange(_ => { - if (this._createPRViewProvider && repository.state.HEAD) { - this._createPRViewProvider.defaultCompareBranch = repository.state.HEAD; - this._treeView?.updateCompareBranch(); - } - }), - ); - } - } - - get isCreatingPullRequest() { - return !!this._createPRViewProvider; - } - - private async ensureDefaultsAreLocal( - folderRepoManager: FolderRepositoryManager, - defaults: PullRequestDefaults, - ): Promise { - if ( - !folderRepoManager.gitHubRepositories.some( - repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo, - ) - ) { - // There is an upstream/parent repo, but the remote for it does not exist in the current workspace. Fall back to using origin instead. - const origin = await folderRepoManager.getOrigin(); - const metadata = await folderRepoManager.getMetadata(origin.remote.remoteName); - return { - owner: metadata.owner.login, - repo: metadata.name, - base: metadata.default_branch, - }; - } else { - return defaults; - } - } - - async create( - telemetry: ITelemetry, - extensionUri: vscode.Uri, - folderRepoManager: FolderRepositoryManager, - compareBranch: string | undefined, - callback: (pullRequestModel: PullRequestModel) => Promise, - ) { - this.reset(); - - this._postCreateCallback = callback; - await folderRepoManager.loginAndUpdate(); - vscode.commands.executeCommand('setContext', 'github:createPullRequest', true); - - const branch = - ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? - folderRepoManager.repository.state.HEAD)!; - - if (!this._createPRViewProvider) { - const pullRequestDefaults = await this.ensureDefaultsAreLocal( - folderRepoManager, - await folderRepoManager.getPullRequestDefaults(branch), - ); - - const compareOrigin = await folderRepoManager.getOrigin(branch); - const model = new CreatePullRequestDataModel(folderRepoManager, pullRequestDefaults.owner, pullRequestDefaults.base, compareOrigin.remote.owner, branch.name!); - this._createPRViewProvider = new CreatePullRequestViewProviderNew( - telemetry, - model, - extensionUri, - folderRepoManager, - pullRequestDefaults, - branch - ); - - this._treeView = new CompareChanges( - folderRepoManager, - model - ); - - this.registerListeners(folderRepoManager.repository, !compareBranch); - - this._disposables.push( - vscode.window.registerWebviewViewProvider( - this._createPRViewProvider.viewType, - this._createPRViewProvider, - ), - ); - } - - this._createPRViewProvider.show(branch); - } - - private reset() { - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); - - this._createPRViewProvider?.dispose(); - this._createPRViewProvider = undefined; - - this._treeView?.dispose(); - this._treeView = undefined; - this._postCreateCallback = undefined; - - dispose(this._disposables); - } - - dispose() { - this.reset(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { ITelemetry } from '../common/telemetry'; +import { dispose } from '../common/utils'; +import { CreatePullRequestViewProviderNew } from '../github/createPRViewProviderNew'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { CompareChanges } from './compareChangesTreeDataProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; + +export class CreatePullRequestHelper implements vscode.Disposable { + private _disposables: vscode.Disposable[] = []; + private _createPRViewProvider: CreatePullRequestViewProviderNew | undefined; + private _treeView: CompareChanges | undefined; + private _postCreateCallback: ((pullRequestModel: PullRequestModel) => Promise) | undefined; + + constructor() { } + + private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { + this._disposables.push( + this._createPRViewProvider!.onDone(async createdPR => { + if (createdPR) { + await CreatePullRequestViewProviderNew.withProgress(async () => { + return this._postCreateCallback?.(createdPR); + }); + } + this.dispose(); + }), + ); + + this._disposables.push( + this._createPRViewProvider!.onDidChangeCompareBranch(compareBranch => { + this._treeView?.updateCompareBranch(compareBranch); + }), + ); + + this._disposables.push( + this._createPRViewProvider!.onDidChangeCompareRemote(compareRemote => { + if (this._treeView) { + this._treeView.compareOwner = compareRemote.owner; + } + }), + ); + + this._disposables.push( + this._createPRViewProvider!.onDidChangeBaseBranch(baseBranch => { + this._treeView?.updateBaseBranch(baseBranch); + }), + ); + + this._disposables.push( + this._createPRViewProvider!.onDidChangeBaseRemote(remoteInfo => { + this._treeView?.updateBaseOwner(remoteInfo.owner); + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addAssignees(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addReviewers(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { + return this._createPRViewProvider?.addLabels(); + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addMilestone(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuCreate', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, false, undefined); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuDraft', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(true, false, undefined); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, undefined, true); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuMerge', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'merge'); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuSquash', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'squash'); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuRebase', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'rebase'); + } + }) + ); + + if (usingCurrentBranchAsCompare) { + this._disposables.push( + repository.state.onDidChange(_ => { + if (this._createPRViewProvider && repository.state.HEAD) { + this._createPRViewProvider.defaultCompareBranch = repository.state.HEAD; + this._treeView?.updateCompareBranch(); + } + }), + ); + } + } + + get isCreatingPullRequest() { + return !!this._createPRViewProvider; + } + + private async ensureDefaultsAreLocal( + folderRepoManager: FolderRepositoryManager, + defaults: PullRequestDefaults, + ): Promise { + if ( + !folderRepoManager.gitHubRepositories.some( + repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo, + ) + ) { + // There is an upstream/parent repo, but the remote for it does not exist in the current workspace. Fall back to using origin instead. + const origin = await folderRepoManager.getOrigin(); + const metadata = await folderRepoManager.getMetadata(origin.remote.remoteName); + return { + owner: metadata.owner.login, + repo: metadata.name, + base: metadata.default_branch, + }; + } else { + return defaults; + } + } + + async create( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + folderRepoManager: FolderRepositoryManager, + compareBranch: string | undefined, + callback: (pullRequestModel: PullRequestModel) => Promise, + ) { + this.reset(); + + this._postCreateCallback = callback; + await folderRepoManager.loginAndUpdate(); + vscode.commands.executeCommand('setContext', 'github:createPullRequest', true); + + const branch = + ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? + folderRepoManager.repository.state.HEAD)!; + + if (!this._createPRViewProvider) { + const pullRequestDefaults = await this.ensureDefaultsAreLocal( + folderRepoManager, + await folderRepoManager.getPullRequestDefaults(branch), + ); + + const compareOrigin = await folderRepoManager.getOrigin(branch); + const model = new CreatePullRequestDataModel(folderRepoManager, pullRequestDefaults.owner, pullRequestDefaults.base, compareOrigin.remote.owner, branch.name!); + this._createPRViewProvider = new CreatePullRequestViewProviderNew( + telemetry, + model, + extensionUri, + folderRepoManager, + pullRequestDefaults, + branch + ); + + this._treeView = new CompareChanges( + folderRepoManager, + model + ); + + this.registerListeners(folderRepoManager.repository, !compareBranch); + + this._disposables.push( + vscode.window.registerWebviewViewProvider( + this._createPRViewProvider.viewType, + this._createPRViewProvider, + ), + ); + } + + this._createPRViewProvider.show(branch); + } + + private reset() { + vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); + + this._createPRViewProvider?.dispose(); + this._createPRViewProvider = undefined; + + this._treeView?.dispose(); + this._treeView = undefined; + this._postCreateCallback = undefined; + + dispose(this._disposables); + } + + dispose() { + this.reset(); + } +} diff --git a/src/view/fileChangeModel.ts b/src/view/fileChangeModel.ts index 85ac9833e8..6375b431e1 100644 --- a/src/view/fileChangeModel.ts +++ b/src/view/fileChangeModel.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { ViewedState } from '../common/comment'; import { DiffHunk, parsePatch } from '../common/diffHunk'; @@ -230,4 +231,4 @@ export class RemoteFileChangeModel extends FileChangeModel { change.previousFileName ); } -} \ No newline at end of file +} diff --git a/src/view/fileTypeDecorationProvider.ts b/src/view/fileTypeDecorationProvider.ts index c8f10785bc..e55380a59e 100644 --- a/src/view/fileTypeDecorationProvider.ts +++ b/src/view/fileTypeDecorationProvider.ts @@ -1,182 +1,183 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { GitChangeType } from '../common/file'; -import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams, Schemes, toResourceUri } from '../common/uri'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -export class FileTypeDecorationProvider implements vscode.FileDecorationProvider { - private _disposables: vscode.Disposable[] = []; - private _gitHubReposListeners: vscode.Disposable[] = []; - private _pullRequestListeners: vscode.Disposable[] = []; - private _fileViewedListeners: vscode.Disposable[] = []; - - _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - - - constructor(private _repositoriesManager: RepositoriesManager) { - this._disposables.push(vscode.window.registerFileDecorationProvider(this)); - this._registerListeners(); - } - - private _registerFileViewedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel) { - return model.onDidChangeFileViewedState(changed => { - changed.changed.forEach(change => { - const uri = vscode.Uri.joinPath(folderManager.repository.rootUri, change.fileName); - const fileChange = model.fileChanges.get(change.fileName); - if (fileChange) { - const fileChangeUri = toResourceUri(uri, model.number, change.fileName, fileChange.status, fileChange.previousFileName); - this._onDidChangeFileDecorations.fire(fileChangeUri); - this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: folderManager.repository.rootUri.scheme })); - this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: Schemes.Pr, authority: '' })); - } - }); - }); - } - - private _registerPullRequestAddedListeners(folderManager: FolderRepositoryManager) { - folderManager.gitHubRepositories.forEach(gitHubRepo => { - this._pullRequestListeners.push(gitHubRepo.onDidAddPullRequest(model => { - this._fileViewedListeners.push(this._registerFileViewedListeners(folderManager, model)); - })); - this._fileViewedListeners.push(...Array.from(gitHubRepo.pullRequestModels.values()).map(model => { - return this._registerFileViewedListeners(folderManager, model); - })); - }); - } - - private _registerRepositoriesChangedListeners() { - this._gitHubReposListeners.forEach(disposable => disposable.dispose()); - this._gitHubReposListeners = []; - this._pullRequestListeners.forEach(disposable => disposable.dispose()); - this._pullRequestListeners = []; - this._fileViewedListeners.forEach(disposable => disposable.dispose()); - this._fileViewedListeners = []; - this._repositoriesManager.folderManagers.forEach(folderManager => { - this._gitHubReposListeners.push(folderManager.onDidChangeRepositories(() => { - this._registerPullRequestAddedListeners(folderManager,); - })); - }); - } - - private _registerListeners() { - this._registerRepositoriesChangedListeners(); - this._disposables.push(this._repositoriesManager.onDidChangeFolderRepositories(() => { - this._registerRepositoriesChangedListeners(); - })); - - } - - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult { - if (!uri.query) { - return; - } - - const fileChangeUriParams = fromFileChangeNodeUri(uri); - if (fileChangeUriParams && fileChangeUriParams.status !== undefined) { - return { - propagate: false, - badge: this.letter(fileChangeUriParams.status), - color: this.color(fileChangeUriParams.status), - tooltip: this.tooltip(fileChangeUriParams) - }; - } - - const prParams = fromPRUri(uri); - - if (prParams && prParams.status !== undefined) { - return { - propagate: false, - badge: this.letter(prParams.status), - color: this.color(prParams.status), - tooltip: this.tooltip(prParams) - }; - } - - return undefined; - } - - gitColors(status: GitChangeType): string | undefined { - switch (status) { - case GitChangeType.MODIFY: - return 'gitDecoration.modifiedResourceForeground'; - case GitChangeType.ADD: - return 'gitDecoration.addedResourceForeground'; - case GitChangeType.DELETE: - return 'gitDecoration.deletedResourceForeground'; - case GitChangeType.RENAME: - return 'gitDecoration.renamedResourceForeground'; - case GitChangeType.UNKNOWN: - return undefined; - case GitChangeType.UNMERGED: - return 'gitDecoration.conflictingResourceForeground'; - } - } - - remoteReposColors(status: GitChangeType): string | undefined { - switch (status) { - case GitChangeType.MODIFY: - return 'remoteHub.decorations.modifiedForegroundColor'; - case GitChangeType.ADD: - return 'remoteHub.decorations.addedForegroundColor'; - case GitChangeType.DELETE: - return 'remoteHub.decorations.deletedForegroundColor'; - case GitChangeType.RENAME: - return 'remoteHub.decorations.incomingRenamedForegroundColor'; - case GitChangeType.UNKNOWN: - return undefined; - case GitChangeType.UNMERGED: - return 'remoteHub.decorations.conflictForegroundColor'; - } - } - - color(status: GitChangeType): vscode.ThemeColor | undefined { - let color: string | undefined = vscode.extensions.getExtension('vscode.git') ? this.gitColors(status) : this.remoteReposColors(status); - return color ? new vscode.ThemeColor(color) : undefined; - } - - letter(status: GitChangeType): string { - - switch (status) { - case GitChangeType.MODIFY: - return 'M'; - case GitChangeType.ADD: - return 'A'; - case GitChangeType.DELETE: - return 'D'; - case GitChangeType.RENAME: - return 'R'; - case GitChangeType.UNKNOWN: - return 'U'; - case GitChangeType.UNMERGED: - return 'C'; - } - - return ''; - } - - tooltip(change: FileChangeNodeUriParams | PRUriParams) { - if ((change.status === GitChangeType.RENAME) && change.previousFileName) { - return `Renamed ${change.previousFileName} to ${path.basename(change.fileName)}`; - } - } - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - this._gitHubReposListeners.forEach(dispose => dispose.dispose()); - this._pullRequestListeners.forEach(dispose => dispose.dispose()); - this._fileViewedListeners.forEach(dispose => dispose.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { GitChangeType } from '../common/file'; +import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams, Schemes, toResourceUri } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class FileTypeDecorationProvider implements vscode.FileDecorationProvider { + private _disposables: vscode.Disposable[] = []; + private _gitHubReposListeners: vscode.Disposable[] = []; + private _pullRequestListeners: vscode.Disposable[] = []; + private _fileViewedListeners: vscode.Disposable[] = []; + + _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + + + constructor(private _repositoriesManager: RepositoriesManager) { + this._disposables.push(vscode.window.registerFileDecorationProvider(this)); + this._registerListeners(); + } + + private _registerFileViewedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel) { + return model.onDidChangeFileViewedState(changed => { + changed.changed.forEach(change => { + const uri = vscode.Uri.joinPath(folderManager.repository.rootUri, change.fileName); + const fileChange = model.fileChanges.get(change.fileName); + if (fileChange) { + const fileChangeUri = toResourceUri(uri, model.number, change.fileName, fileChange.status, fileChange.previousFileName); + this._onDidChangeFileDecorations.fire(fileChangeUri); + this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: folderManager.repository.rootUri.scheme })); + this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: Schemes.Pr, authority: '' })); + } + }); + }); + } + + private _registerPullRequestAddedListeners(folderManager: FolderRepositoryManager) { + folderManager.gitHubRepositories.forEach(gitHubRepo => { + this._pullRequestListeners.push(gitHubRepo.onDidAddPullRequest(model => { + this._fileViewedListeners.push(this._registerFileViewedListeners(folderManager, model)); + })); + this._fileViewedListeners.push(...Array.from(gitHubRepo.pullRequestModels.values()).map(model => { + return this._registerFileViewedListeners(folderManager, model); + })); + }); + } + + private _registerRepositoriesChangedListeners() { + this._gitHubReposListeners.forEach(disposable => disposable.dispose()); + this._gitHubReposListeners = []; + this._pullRequestListeners.forEach(disposable => disposable.dispose()); + this._pullRequestListeners = []; + this._fileViewedListeners.forEach(disposable => disposable.dispose()); + this._fileViewedListeners = []; + this._repositoriesManager.folderManagers.forEach(folderManager => { + this._gitHubReposListeners.push(folderManager.onDidChangeRepositories(() => { + this._registerPullRequestAddedListeners(folderManager,); + })); + }); + } + + private _registerListeners() { + this._registerRepositoriesChangedListeners(); + this._disposables.push(this._repositoriesManager.onDidChangeFolderRepositories(() => { + this._registerRepositoriesChangedListeners(); + })); + + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + if (!uri.query) { + return; + } + + const fileChangeUriParams = fromFileChangeNodeUri(uri); + if (fileChangeUriParams && fileChangeUriParams.status !== undefined) { + return { + propagate: false, + badge: this.letter(fileChangeUriParams.status), + color: this.color(fileChangeUriParams.status), + tooltip: this.tooltip(fileChangeUriParams) + }; + } + + const prParams = fromPRUri(uri); + + if (prParams && prParams.status !== undefined) { + return { + propagate: false, + badge: this.letter(prParams.status), + color: this.color(prParams.status), + tooltip: this.tooltip(prParams) + }; + } + + return undefined; + } + + gitColors(status: GitChangeType): string | undefined { + switch (status) { + case GitChangeType.MODIFY: + return 'gitDecoration.modifiedResourceForeground'; + case GitChangeType.ADD: + return 'gitDecoration.addedResourceForeground'; + case GitChangeType.DELETE: + return 'gitDecoration.deletedResourceForeground'; + case GitChangeType.RENAME: + return 'gitDecoration.renamedResourceForeground'; + case GitChangeType.UNKNOWN: + return undefined; + case GitChangeType.UNMERGED: + return 'gitDecoration.conflictingResourceForeground'; + } + } + + remoteReposColors(status: GitChangeType): string | undefined { + switch (status) { + case GitChangeType.MODIFY: + return 'remoteHub.decorations.modifiedForegroundColor'; + case GitChangeType.ADD: + return 'remoteHub.decorations.addedForegroundColor'; + case GitChangeType.DELETE: + return 'remoteHub.decorations.deletedForegroundColor'; + case GitChangeType.RENAME: + return 'remoteHub.decorations.incomingRenamedForegroundColor'; + case GitChangeType.UNKNOWN: + return undefined; + case GitChangeType.UNMERGED: + return 'remoteHub.decorations.conflictForegroundColor'; + } + } + + color(status: GitChangeType): vscode.ThemeColor | undefined { + let color: string | undefined = vscode.extensions.getExtension('vscode.git') ? this.gitColors(status) : this.remoteReposColors(status); + return color ? new vscode.ThemeColor(color) : undefined; + } + + letter(status: GitChangeType): string { + + switch (status) { + case GitChangeType.MODIFY: + return 'M'; + case GitChangeType.ADD: + return 'A'; + case GitChangeType.DELETE: + return 'D'; + case GitChangeType.RENAME: + return 'R'; + case GitChangeType.UNKNOWN: + return 'U'; + case GitChangeType.UNMERGED: + return 'C'; + } + + return ''; + } + + tooltip(change: FileChangeNodeUriParams | PRUriParams) { + if ((change.status === GitChangeType.RENAME) && change.previousFileName) { + return `Renamed ${change.previousFileName} to ${path.basename(change.fileName)}`; + } + } + + dispose() { + this._disposables.forEach(dispose => dispose.dispose()); + this._gitHubReposListeners.forEach(dispose => dispose.dispose()); + this._pullRequestListeners.forEach(dispose => dispose.dispose()); + this._fileViewedListeners.forEach(dispose => dispose.dispose()); + } +} diff --git a/src/view/gitContentProvider.ts b/src/view/gitContentProvider.ts index 13a4d0ff1c..21ef82a183 100644 --- a/src/view/gitContentProvider.ts +++ b/src/view/gitContentProvider.ts @@ -1,97 +1,98 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as pathLib from 'path'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import Logger from '../common/logger'; -import { fromReviewUri } from '../common/uri'; -import { CredentialStore } from '../github/credentials'; -import { getRepositoryForFile } from '../github/utils'; -import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; -import { ReviewManager } from './reviewManager'; - -export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { - private _fallback?: (uri: vscode.Uri) => Promise; - - constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly reviewManagers: ReviewManager[]) { - super(gitAPI, credentialStore); - } - - private getChangeModelForFile(file: vscode.Uri) { - for (const manager of this.reviewManagers) { - for (const change of manager.reviewModel.localFileChanges) { - if ((change.changeModel.filePath.authority === file.authority) && (change.changeModel.filePath.path === file.path)) { - return change.changeModel; - } - } - } - } - - private async getRepositoryForFile(file: vscode.Uri): Promise { - await this.waitForAuth(); - if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { - await this.waitForRepos(4000); - } - - return getRepositoryForFile(this.gitAPI, file); - } - - async readFile(uri: vscode.Uri): Promise { - if (!this._fallback) { - return new TextEncoder().encode(''); - } - - const { path, commit, rootPath } = fromReviewUri(uri.query); - - if (!path || !commit) { - return new TextEncoder().encode(''); - } - - const repository = await this.getRepositoryForFile(vscode.Uri.file(rootPath)); - if (!repository) { - vscode.window.showErrorMessage(`We couldn't find an open repository for ${commit} locally.`); - return new TextEncoder().encode(''); - } - - const absolutePath = pathLib.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); - let content: string | undefined; - try { - Logger.appendLine(`Getting change model (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); - content = await this.getChangeModelForFile(uri)?.showBase(); - if (!content) { - Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); - content = await repository.show(commit, absolutePath); - } - if (!content) { - throw new Error(); - } - } catch (_) { - Logger.appendLine('Using fallback content provider.', 'GitContentFileSystemProvider'); - content = await this._fallback(uri); - if (!content) { - // Content does not exist for the base or modified file for a file deletion or addition. - // Manually check if the commit exists before notifying the user. - - try { - await repository.getCommit(commit); - } catch (err) { - Logger.error(err); - vscode.window.showErrorMessage( - `We couldn't find commit ${commit} locally. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push`, - ); - } - } - } - - return new TextEncoder().encode(content || ''); - } - - registerTextDocumentContentFallback(provider: (uri: vscode.Uri) => Promise) { - this._fallback = provider; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { fromReviewUri } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { getRepositoryForFile } from '../github/utils'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; +import { ReviewManager } from './reviewManager'; + +export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { + private _fallback?: (uri: vscode.Uri) => Promise; + + constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly reviewManagers: ReviewManager[]) { + super(gitAPI, credentialStore); + } + + private getChangeModelForFile(file: vscode.Uri) { + for (const manager of this.reviewManagers) { + for (const change of manager.reviewModel.localFileChanges) { + if ((change.changeModel.filePath.authority === file.authority) && (change.changeModel.filePath.path === file.path)) { + return change.changeModel; + } + } + } + } + + private async getRepositoryForFile(file: vscode.Uri): Promise { + await this.waitForAuth(); + if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { + await this.waitForRepos(4000); + } + + return getRepositoryForFile(this.gitAPI, file); + } + + async readFile(uri: vscode.Uri): Promise { + if (!this._fallback) { + return new TextEncoder().encode(''); + } + + const { path, commit, rootPath } = fromReviewUri(uri.query); + + if (!path || !commit) { + return new TextEncoder().encode(''); + } + + const repository = await this.getRepositoryForFile(vscode.Uri.file(rootPath)); + if (!repository) { + vscode.window.showErrorMessage(`We couldn't find an open repository for ${commit} locally.`); + return new TextEncoder().encode(''); + } + + const absolutePath = pathLib.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); + let content: string | undefined; + try { + Logger.appendLine(`Getting change model (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + content = await this.getChangeModelForFile(uri)?.showBase(); + if (!content) { + Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + content = await repository.show(commit, absolutePath); + } + if (!content) { + throw new Error(); + } + } catch (_) { + Logger.appendLine('Using fallback content provider.', 'GitContentFileSystemProvider'); + content = await this._fallback(uri); + if (!content) { + // Content does not exist for the base or modified file for a file deletion or addition. + // Manually check if the commit exists before notifying the user. + + try { + await repository.getCommit(commit); + } catch (err) { + Logger.error(err); + vscode.window.showErrorMessage( + `We couldn't find commit ${commit} locally. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push`, + ); + } + } + } + + return new TextEncoder().encode(content || ''); + } + + registerTextDocumentContentFallback(provider: (uri: vscode.Uri) => Promise) { + this._fallback = provider; + } +} diff --git a/src/view/gitHubContentProvider.ts b/src/view/gitHubContentProvider.ts index 03e557704e..b26f6ea0ea 100644 --- a/src/view/gitHubContentProvider.ts +++ b/src/view/gitHubContentProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as buffer from 'buffer'; import * as vscode from 'vscode'; import { fromGitHubURI } from '../common/uri'; @@ -77,4 +78,4 @@ export class GitContentProvider extends ReadonlyFileSystemProvider { return getGitFileContent(this.folderRepositoryManager, params.fileName, params.branch, !!params.isEmpty); } -} \ No newline at end of file +} diff --git a/src/view/inMemPRContentProvider.ts b/src/view/inMemPRContentProvider.ts index eb1e1ba536..e8fc66e5a3 100644 --- a/src/view/inMemPRContentProvider.ts +++ b/src/view/inMemPRContentProvider.ts @@ -1,250 +1,251 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import { GitApiImpl } from '../api/api1'; -import { DiffChangeType, getModifiedContentFromDiffHunk } from '../common/diffHunk'; -import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; -import Logger from '../common/logger'; -import { fromPRUri, PRUriParams } from '../common/uri'; -import { CredentialStore } from '../github/credentials'; -import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; -import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { FileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; -import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; - -export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { - private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise } = {}; - - constructor(private reposManagers: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { - super(gitAPI, credentialStore); - } - - registerTextDocumentContentProvider( - prNumber: number, - provider: (uri: vscode.Uri) => Promise, - ): vscode.Disposable { - this._prFileChangeContentProviders[prNumber] = provider; - - return { - dispose: () => { - delete this._prFileChangeContentProviders[prNumber]; - }, - }; - } - - private resolveChanges(rawChanges: (SlimFileChange | InMemFileChange)[], pr: PullRequestModel, - folderRepositoryManager: FolderRepositoryManager, - mergeBase: string): (RemoteFileChangeModel | InMemFileChangeModel)[] { - const isCurrentPR = pr.equals(folderRepositoryManager.activePullRequest); - - return rawChanges.map(change => { - if (change instanceof SlimFileChange) { - return new RemoteFileChangeModel(folderRepositoryManager, change, pr); - } - return new InMemFileChangeModel(folderRepositoryManager, - pr as (PullRequestModel & IResolvedPullRequestModel), - change, isCurrentPR, mergeBase); - }); - } - - private waitForGitHubRepos(folderRepositoryManager: FolderRepositoryManager, milliseconds: number) { - return new Promise(resolve => { - const timeout = setTimeout(() => { - disposable.dispose(); - resolve(); - }, milliseconds); - const disposable = folderRepositoryManager.onDidLoadRepositories(e => { - if (e === ReposManagerState.RepositoriesLoaded) { - clearTimeout(timeout); - disposable.dispose(); - resolve(); - } - }); - }); - } - - private async tryRegisterNewProvider(uri: vscode.Uri, prUriParams: PRUriParams) { - await this.waitForAuth(); - if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { - await this.waitForRepos(4000); - } - const folderRepositoryManager = this.reposManagers.getManagerForFile(uri); - if (!folderRepositoryManager) { - return; - } - let repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); - if (!repo) { - // Depending on the git provider, we might not have a GitHub repo right away, even if we already have git repos. - // This can take a long time. - await this.waitForGitHubRepos(folderRepositoryManager, 10000); - repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); - } - if (!repo) { - return; - } - const pr = await folderRepositoryManager.resolvePullRequest(repo.remote.owner, repo.remote.repositoryName, prUriParams.prNumber); - if (!pr) { - return; - } - const rawChanges = await pr.getFileChangesInfo(); - const mergeBase = pr.mergeBase; - if (!mergeBase) { - return; - } - const changes = this.resolveChanges(rawChanges, pr, folderRepositoryManager, mergeBase); - this.registerTextDocumentContentProvider(pr.number, async (uri: vscode.Uri) => { - const params = fromPRUri(uri); - if (!params) { - return ''; - } - const fileChange = changes.find( - contentChange => contentChange.fileName === params.fileName, - ); - - if (!fileChange) { - Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); - return ''; - } - - return provideDocumentContentForChangeModel(folderRepositoryManager, pr, params, fileChange); - }); - } - - private async readFileWithProvider(uri: vscode.Uri, prNumber: number): Promise { - const provider = this._prFileChangeContentProviders[prNumber]; - if (provider) { - const content = await provider(uri); - return new TextEncoder().encode(content); - } - } - - async readFile(uri: vscode.Uri): Promise { - const prUriParams = fromPRUri(uri); - if (!prUriParams || (prUriParams.prNumber === undefined)) { - return new TextEncoder().encode(''); - } - const providerResult = await this.readFileWithProvider(uri, prUriParams.prNumber); - if (providerResult) { - return providerResult; - } - - await this.tryRegisterNewProvider(uri, prUriParams); - return (await this.readFileWithProvider(uri, prUriParams.prNumber)) ?? new TextEncoder().encode(''); - } -} - -let inMemPRFileSystemProvider: InMemPRFileSystemProvider | undefined; - -export function getInMemPRFileSystemProvider(initialize?: { reposManager: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore }): InMemPRFileSystemProvider | undefined { - if (!inMemPRFileSystemProvider && initialize) { - inMemPRFileSystemProvider = new InMemPRFileSystemProvider(initialize.reposManager, initialize.gitAPI, initialize.credentialStore); - } - return inMemPRFileSystemProvider; -} - -export async function provideDocumentContentForChangeModel(folderRepoManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, params: PRUriParams, fileChange: FileChangeModel): Promise { - if ( - (params.isBase && fileChange.status === GitChangeType.ADD) || - (!params.isBase && fileChange.status === GitChangeType.DELETE) - ) { - return ''; - } - - if ((fileChange instanceof RemoteFileChangeModel) || ((fileChange instanceof InMemFileChangeModel) && await fileChange.isPartial())) { - try { - if (params.isBase) { - return pullRequestModel.getFile( - fileChange.previousFileName || fileChange.fileName, - params.baseCommit, - ); - } else { - return pullRequestModel.getFile(fileChange.fileName, params.headCommit); - } - } catch (e) { - Logger.error(`Fetching file content failed: ${e}`, 'PR'); - vscode.window - .showWarningMessage( - 'Opening this file locally failed. Would you like to view it on GitHub?', - 'Open on GitHub', - ) - .then(result => { - if ((result === 'Open on GitHub') && fileChange.blobUrl) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); - } - }); - return ''; - } - } - - if (fileChange instanceof InMemFileChangeModel) { - const readContentFromDiffHunk = - fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; - - if (readContentFromDiffHunk) { - if (params.isBase) { - // left - const left: string[] = []; - const diffHunks = await fileChange.diffHunks(); - for (let i = 0; i < diffHunks.length; i++) { - for (let j = 0; j < diffHunks[i].diffLines.length; j++) { - const diffLine = diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - // nothing - } else if (diffLine.type === DiffChangeType.Delete) { - left.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - left.push(diffLine.text); - } - } - } - - return left.join('\n'); - } else { - const right: string[] = []; - const diffHunks = await fileChange.diffHunks(); - for (let i = 0; i < diffHunks.length; i++) { - for (let j = 0; j < diffHunks[i].diffLines.length; j++) { - const diffLine = diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Delete) { - // nothing - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - right.push(diffLine.text); - } - } - } - - return right.join('\n'); - } - } else { - const originalFileName = - fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; - const originalFilePath = vscode.Uri.joinPath( - folderRepoManager.repository.rootUri, - originalFileName!, - ); - const originalContent = await folderRepoManager.repository.show( - params.baseCommit, - originalFilePath.fsPath, - ); - - if (params.isBase) { - return originalContent; - } else { - return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); - } - } - } - - return ''; -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import { GitApiImpl } from '../api/api1'; +import { DiffChangeType, getModifiedContentFromDiffHunk } from '../common/diffHunk'; +import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; +import Logger from '../common/logger'; +import { fromPRUri, PRUriParams } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { FileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; + +export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { + private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise } = {}; + + constructor(private reposManagers: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { + super(gitAPI, credentialStore); + } + + registerTextDocumentContentProvider( + prNumber: number, + provider: (uri: vscode.Uri) => Promise, + ): vscode.Disposable { + this._prFileChangeContentProviders[prNumber] = provider; + + return { + dispose: () => { + delete this._prFileChangeContentProviders[prNumber]; + }, + }; + } + + private resolveChanges(rawChanges: (SlimFileChange | InMemFileChange)[], pr: PullRequestModel, + folderRepositoryManager: FolderRepositoryManager, + mergeBase: string): (RemoteFileChangeModel | InMemFileChangeModel)[] { + const isCurrentPR = pr.equals(folderRepositoryManager.activePullRequest); + + return rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(folderRepositoryManager, change, pr); + } + return new InMemFileChangeModel(folderRepositoryManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); + }); + } + + private waitForGitHubRepos(folderRepositoryManager: FolderRepositoryManager, milliseconds: number) { + return new Promise(resolve => { + const timeout = setTimeout(() => { + disposable.dispose(); + resolve(); + }, milliseconds); + const disposable = folderRepositoryManager.onDidLoadRepositories(e => { + if (e === ReposManagerState.RepositoriesLoaded) { + clearTimeout(timeout); + disposable.dispose(); + resolve(); + } + }); + }); + } + + private async tryRegisterNewProvider(uri: vscode.Uri, prUriParams: PRUriParams) { + await this.waitForAuth(); + if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { + await this.waitForRepos(4000); + } + const folderRepositoryManager = this.reposManagers.getManagerForFile(uri); + if (!folderRepositoryManager) { + return; + } + let repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + if (!repo) { + // Depending on the git provider, we might not have a GitHub repo right away, even if we already have git repos. + // This can take a long time. + await this.waitForGitHubRepos(folderRepositoryManager, 10000); + repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + } + if (!repo) { + return; + } + const pr = await folderRepositoryManager.resolvePullRequest(repo.remote.owner, repo.remote.repositoryName, prUriParams.prNumber); + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = this.resolveChanges(rawChanges, pr, folderRepositoryManager, mergeBase); + this.registerTextDocumentContentProvider(pr.number, async (uri: vscode.Uri) => { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + const fileChange = changes.find( + contentChange => contentChange.fileName === params.fileName, + ); + + if (!fileChange) { + Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(folderRepositoryManager, pr, params, fileChange); + }); + } + + private async readFileWithProvider(uri: vscode.Uri, prNumber: number): Promise { + const provider = this._prFileChangeContentProviders[prNumber]; + if (provider) { + const content = await provider(uri); + return new TextEncoder().encode(content); + } + } + + async readFile(uri: vscode.Uri): Promise { + const prUriParams = fromPRUri(uri); + if (!prUriParams || (prUriParams.prNumber === undefined)) { + return new TextEncoder().encode(''); + } + const providerResult = await this.readFileWithProvider(uri, prUriParams.prNumber); + if (providerResult) { + return providerResult; + } + + await this.tryRegisterNewProvider(uri, prUriParams); + return (await this.readFileWithProvider(uri, prUriParams.prNumber)) ?? new TextEncoder().encode(''); + } +} + +let inMemPRFileSystemProvider: InMemPRFileSystemProvider | undefined; + +export function getInMemPRFileSystemProvider(initialize?: { reposManager: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore }): InMemPRFileSystemProvider | undefined { + if (!inMemPRFileSystemProvider && initialize) { + inMemPRFileSystemProvider = new InMemPRFileSystemProvider(initialize.reposManager, initialize.gitAPI, initialize.credentialStore); + } + return inMemPRFileSystemProvider; +} + +export async function provideDocumentContentForChangeModel(folderRepoManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, params: PRUriParams, fileChange: FileChangeModel): Promise { + if ( + (params.isBase && fileChange.status === GitChangeType.ADD) || + (!params.isBase && fileChange.status === GitChangeType.DELETE) + ) { + return ''; + } + + if ((fileChange instanceof RemoteFileChangeModel) || ((fileChange instanceof InMemFileChangeModel) && await fileChange.isPartial())) { + try { + if (params.isBase) { + return pullRequestModel.getFile( + fileChange.previousFileName || fileChange.fileName, + params.baseCommit, + ); + } else { + return pullRequestModel.getFile(fileChange.fileName, params.headCommit); + } + } catch (e) { + Logger.error(`Fetching file content failed: ${e}`, 'PR'); + vscode.window + .showWarningMessage( + 'Opening this file locally failed. Would you like to view it on GitHub?', + 'Open on GitHub', + ) + .then(result => { + if ((result === 'Open on GitHub') && fileChange.blobUrl) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); + } + }); + return ''; + } + } + + if (fileChange instanceof InMemFileChangeModel) { + const readContentFromDiffHunk = + fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; + + if (readContentFromDiffHunk) { + if (params.isBase) { + // left + const left: string[] = []; + const diffHunks = await fileChange.diffHunks(); + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + // nothing + } else if (diffLine.type === DiffChangeType.Delete) { + left.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + left.push(diffLine.text); + } + } + } + + return left.join('\n'); + } else { + const right: string[] = []; + const diffHunks = await fileChange.diffHunks(); + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + right.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Delete) { + // nothing + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + right.push(diffLine.text); + } + } + } + + return right.join('\n'); + } + } else { + const originalFileName = + fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; + const originalFilePath = vscode.Uri.joinPath( + folderRepoManager.repository.rootUri, + originalFileName!, + ); + const originalContent = await folderRepoManager.repository.show( + params.baseCommit, + originalFilePath.fsPath, + ); + + if (params.isBase) { + return originalContent; + } else { + return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); + } + } + } + + return ''; +} diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index 98ed904eeb..7eea97c7e3 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -1,194 +1,195 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { GitApiImpl } from '../api/api1'; -import { commands, contexts } from '../common/executeCommands'; -import Logger, { PR_TREE } from '../common/logger'; -import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ProgressHelper } from './progress'; -import { ReviewModel } from './reviewModel'; -import { DescriptionNode } from './treeNodes/descriptionNode'; -import { GitFileChangeNode } from './treeNodes/fileChangeNode'; -import { RepositoryChangesNode } from './treeNodes/repositoryChangesNode'; -import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; -import { TreeUtils } from './treeNodes/treeUtils'; - -export class PullRequestChangesTreeDataProvider extends vscode.Disposable implements vscode.TreeDataProvider, BaseTreeNode { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private _disposables: vscode.Disposable[] = []; - - private _pullRequestManagerMap: Map = new Map(); - private _view: vscode.TreeView; - private _children: TreeNode[] | undefined; - - public get view(): vscode.TreeView { - return this._view; - } - - constructor(private _context: vscode.ExtensionContext, private _git: GitApiImpl, private _reposManager: RepositoriesManager) { - super(() => this.dispose()); - this._view = vscode.window.createTreeView('prStatus:github', { - treeDataProvider: this, - showCollapseAll: true, - }); - this._context.subscriptions.push(this._view); - - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { - this._onDidChangeTreeData.fire(); - const layout = vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(FILE_LIST_LAYOUT); - await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - } else if (e.affectsConfiguration(`${GIT}.${OPEN_DIFF_ON_CLICK}`)) { - this._onDidChangeTreeData.fire(); - } - }), - ); - - this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); - } - - refresh(treeNode?: TreeNode) { - this._onDidChangeTreeData.fire(treeNode); - } - - private updateViewTitle(): void { - let pullRequestNumber: number | undefined; - if (this._pullRequestManagerMap.size === 1) { - const pullRequestIterator = this._pullRequestManagerMap.values().next(); - if (!pullRequestIterator.done) { - pullRequestNumber = pullRequestIterator.value.pullRequestModel.number; - } - } - - this._view.title = pullRequestNumber - ? vscode.l10n.t('Changes in Pull Request #{0}', pullRequestNumber) - : (this._pullRequestManagerMap.size > 1 ? vscode.l10n.t('Changes in Pull Requests') : vscode.l10n.t('Changes in Pull Request')); - } - - async addPrToView( - pullRequestManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - reviewModel: ReviewModel, - shouldReveal: boolean, - progress: ProgressHelper - ) { - Logger.appendLine(`Adding PR #${pullRequestModel.number} to tree`, PR_TREE); - if (this._pullRequestManagerMap.has(pullRequestManager)) { - const existingNode = this._pullRequestManagerMap.get(pullRequestManager); - if (existingNode && (existingNode.pullRequestModel === pullRequestModel)) { - Logger.appendLine(`PR #${pullRequestModel.number} already exists in tree`, PR_TREE); - return; - } else { - existingNode?.dispose(); - } - } - const node: RepositoryChangesNode = new RepositoryChangesNode( - this, - pullRequestModel, - pullRequestManager, - reviewModel, - progress - ); - this._pullRequestManagerMap.set(pullRequestManager, node); - this.updateViewTitle(); - - await this.setReviewModeContexts(); - this._onDidChangeTreeData.fire(); - - if (shouldReveal) { - this.reveal(node); - } - } - - private async setReviewModeContexts() { - await commands.setContext(contexts.IN_REVIEW_MODE, this._pullRequestManagerMap.size > 0); - - const rootUrisNotInReviewMode: string[] = []; - const rootUrisInReviewMode: string[] = []; - this._git.repositories.forEach(repo => { - const folderManager = this._reposManager.getManagerForFile(repo.rootUri); - if (folderManager && !this._pullRequestManagerMap.has(folderManager)) { - rootUrisNotInReviewMode.push(repo.rootUri.toString()); - } else if (folderManager) { - rootUrisInReviewMode.push(repo.rootUri.toString()); - } - }); - await commands.setContext(contexts.REPOS_NOT_IN_REVIEW_MODE, rootUrisNotInReviewMode); - await commands.setContext(contexts.REPOS_IN_REVIEW_MODE, rootUrisInReviewMode); - } - - async removePrFromView(pullRequestManager: FolderRepositoryManager) { - const oldPR = this._pullRequestManagerMap.has(pullRequestManager) ? this._pullRequestManagerMap.get(pullRequestManager) : undefined; - if (oldPR) { - Logger.appendLine(`Removing PR #${oldPR.pullRequestModel.number} from tree`, PR_TREE); - } - oldPR?.dispose(); - this._pullRequestManagerMap.delete(pullRequestManager); - this.updateViewTitle(); - - await this.setReviewModeContexts(); - this._onDidChangeTreeData.fire(); - } - - getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { - return element.getTreeItem(); - } - - getParent(element: TreeNode) { - return element.getParent(); - } - - async reveal( - element: TreeNode, - options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, - ): Promise { - try { - await this._view.reveal(element, options); - } catch (e) { - Logger.error(e, PR_TREE); - } - } - - get children(): TreeNode[] | undefined { - return this._children; - } - - async getChildren(element?: TreeNode): Promise { - if (!element) { - this._children = []; - if (this._pullRequestManagerMap.size >= 1) { - for (const item of this._pullRequestManagerMap.values()) { - this._children.push(item); - } - } - return this._children; - } else { - return await element.getChildren(); - } - } - - getDescriptionNode(folderRepoManager: FolderRepositoryManager): DescriptionNode | undefined { - return this._pullRequestManagerMap.get(folderRepoManager); - } - - async resolveTreeItem?(item: vscode.TreeItem, element: TreeNode): Promise { - if (element instanceof GitFileChangeNode) { - await element.resolve(); - } - return element; - } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { GitApiImpl } from '../api/api1'; +import { commands, contexts } from '../common/executeCommands'; +import Logger, { PR_TREE } from '../common/logger'; +import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ProgressHelper } from './progress'; +import { ReviewModel } from './reviewModel'; +import { DescriptionNode } from './treeNodes/descriptionNode'; +import { GitFileChangeNode } from './treeNodes/fileChangeNode'; +import { RepositoryChangesNode } from './treeNodes/repositoryChangesNode'; +import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; + +export class PullRequestChangesTreeDataProvider extends vscode.Disposable implements vscode.TreeDataProvider, BaseTreeNode { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _disposables: vscode.Disposable[] = []; + + private _pullRequestManagerMap: Map = new Map(); + private _view: vscode.TreeView; + private _children: TreeNode[] | undefined; + + public get view(): vscode.TreeView { + return this._view; + } + + constructor(private _context: vscode.ExtensionContext, private _git: GitApiImpl, private _reposManager: RepositoriesManager) { + super(() => this.dispose()); + this._view = vscode.window.createTreeView('prStatus:github', { + treeDataProvider: this, + showCollapseAll: true, + }); + this._context.subscriptions.push(this._view); + + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + this._onDidChangeTreeData.fire(); + const layout = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(FILE_LIST_LAYOUT); + await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); + } else if (e.affectsConfiguration(`${GIT}.${OPEN_DIFF_ON_CLICK}`)) { + this._onDidChangeTreeData.fire(); + } + }), + ); + + this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); + } + + refresh(treeNode?: TreeNode) { + this._onDidChangeTreeData.fire(treeNode); + } + + private updateViewTitle(): void { + let pullRequestNumber: number | undefined; + if (this._pullRequestManagerMap.size === 1) { + const pullRequestIterator = this._pullRequestManagerMap.values().next(); + if (!pullRequestIterator.done) { + pullRequestNumber = pullRequestIterator.value.pullRequestModel.number; + } + } + + this._view.title = pullRequestNumber + ? vscode.l10n.t('Changes in Pull Request #{0}', pullRequestNumber) + : (this._pullRequestManagerMap.size > 1 ? vscode.l10n.t('Changes in Pull Requests') : vscode.l10n.t('Changes in Pull Request')); + } + + async addPrToView( + pullRequestManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + reviewModel: ReviewModel, + shouldReveal: boolean, + progress: ProgressHelper + ) { + Logger.appendLine(`Adding PR #${pullRequestModel.number} to tree`, PR_TREE); + if (this._pullRequestManagerMap.has(pullRequestManager)) { + const existingNode = this._pullRequestManagerMap.get(pullRequestManager); + if (existingNode && (existingNode.pullRequestModel === pullRequestModel)) { + Logger.appendLine(`PR #${pullRequestModel.number} already exists in tree`, PR_TREE); + return; + } else { + existingNode?.dispose(); + } + } + const node: RepositoryChangesNode = new RepositoryChangesNode( + this, + pullRequestModel, + pullRequestManager, + reviewModel, + progress + ); + this._pullRequestManagerMap.set(pullRequestManager, node); + this.updateViewTitle(); + + await this.setReviewModeContexts(); + this._onDidChangeTreeData.fire(); + + if (shouldReveal) { + this.reveal(node); + } + } + + private async setReviewModeContexts() { + await commands.setContext(contexts.IN_REVIEW_MODE, this._pullRequestManagerMap.size > 0); + + const rootUrisNotInReviewMode: string[] = []; + const rootUrisInReviewMode: string[] = []; + this._git.repositories.forEach(repo => { + const folderManager = this._reposManager.getManagerForFile(repo.rootUri); + if (folderManager && !this._pullRequestManagerMap.has(folderManager)) { + rootUrisNotInReviewMode.push(repo.rootUri.toString()); + } else if (folderManager) { + rootUrisInReviewMode.push(repo.rootUri.toString()); + } + }); + await commands.setContext(contexts.REPOS_NOT_IN_REVIEW_MODE, rootUrisNotInReviewMode); + await commands.setContext(contexts.REPOS_IN_REVIEW_MODE, rootUrisInReviewMode); + } + + async removePrFromView(pullRequestManager: FolderRepositoryManager) { + const oldPR = this._pullRequestManagerMap.has(pullRequestManager) ? this._pullRequestManagerMap.get(pullRequestManager) : undefined; + if (oldPR) { + Logger.appendLine(`Removing PR #${oldPR.pullRequestModel.number} from tree`, PR_TREE); + } + oldPR?.dispose(); + this._pullRequestManagerMap.delete(pullRequestManager); + this.updateViewTitle(); + + await this.setReviewModeContexts(); + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { + return element.getTreeItem(); + } + + getParent(element: TreeNode) { + return element.getParent(); + } + + async reveal( + element: TreeNode, + options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, + ): Promise { + try { + await this._view.reveal(element, options); + } catch (e) { + Logger.error(e, PR_TREE); + } + } + + get children(): TreeNode[] | undefined { + return this._children; + } + + async getChildren(element?: TreeNode): Promise { + if (!element) { + this._children = []; + if (this._pullRequestManagerMap.size >= 1) { + for (const item of this._pullRequestManagerMap.values()) { + this._children.push(item); + } + } + return this._children; + } else { + return await element.getChildren(); + } + } + + getDescriptionNode(folderRepoManager: FolderRepositoryManager): DescriptionNode | undefined { + return this._pullRequestManagerMap.get(folderRepoManager); + } + + async resolveTreeItem?(item: vscode.TreeItem, element: TreeNode): Promise { + if (element instanceof GitFileChangeNode) { + await element.resolve(); + } + return element; + } + + dispose() { + this._disposables.forEach(disposable => disposable.dispose()); + } +} diff --git a/src/view/prNotificationDecorationProvider.ts b/src/view/prNotificationDecorationProvider.ts index 7fac6fb3c9..3ebce92a95 100644 --- a/src/view/prNotificationDecorationProvider.ts +++ b/src/view/prNotificationDecorationProvider.ts @@ -1,52 +1,53 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { fromPRNodeUri } from '../common/uri'; -import { NotificationProvider } from '../github/notifications'; - -export class PRNotificationDecorationProvider implements vscode.FileDecorationProvider { - private _disposables: vscode.Disposable[] = []; - - private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - - - constructor(private readonly _notificationProvider: NotificationProvider) { - this._disposables.push(vscode.window.registerFileDecorationProvider(this)); - this._disposables.push( - this._notificationProvider.onDidChangeNotifications(PRNodeUris => this._onDidChangeFileDecorations.fire(PRNodeUris)) - ); - } - - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult { - if (!uri.query) { - return; - } - - const prNodeParams = fromPRNodeUri(uri); - - if (prNodeParams && this._notificationProvider.hasNotification(prNodeParams.prIdentifier)) { - return { - propagate: false, - color: new vscode.ThemeColor('pullRequests.notification'), - badge: '●', - tooltip: 'unread notification' - }; - } - - return undefined; - } - - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { fromPRNodeUri } from '../common/uri'; +import { NotificationProvider } from '../github/notifications'; + +export class PRNotificationDecorationProvider implements vscode.FileDecorationProvider { + private _disposables: vscode.Disposable[] = []; + + private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + + + constructor(private readonly _notificationProvider: NotificationProvider) { + this._disposables.push(vscode.window.registerFileDecorationProvider(this)); + this._disposables.push( + this._notificationProvider.onDidChangeNotifications(PRNodeUris => this._onDidChangeFileDecorations.fire(PRNodeUris)) + ); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + if (!uri.query) { + return; + } + + const prNodeParams = fromPRNodeUri(uri); + + if (prNodeParams && this._notificationProvider.hasNotification(prNodeParams.prIdentifier)) { + return { + propagate: false, + color: new vscode.ThemeColor('pullRequests.notification'), + badge: '●', + tooltip: 'unread notification' + }; + } + + return undefined; + } + + + dispose() { + this._disposables.forEach(dispose => dispose.dispose()); + } +} diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts index 2388cda84c..3a38fc1d1b 100644 --- a/src/view/prStatusDecorationProvider.ts +++ b/src/view/prStatusDecorationProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { createPRNodeUri, fromPRNodeUri, Schemes } from '../common/uri'; import { dispose } from '../common/utils'; @@ -89,4 +90,4 @@ export class PRStatusDecorationProvider implements vscode.FileDecorationProvider dispose(this._disposables); } -} \ No newline at end of file +} diff --git a/src/view/progress.ts b/src/view/progress.ts index 04ef0370bf..c7f12e2702 100644 --- a/src/view/progress.ts +++ b/src/view/progress.ts @@ -1,28 +1,29 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; - -export class ProgressHelper { - private _progress: Promise = Promise.resolve(); - private _endProgress: vscode.EventEmitter = new vscode.EventEmitter(); - - get progress(): Promise { - return this._progress; - } - startProgress() { - this.endProgress(); - this._progress = new Promise(resolve => { - const disposable = this._endProgress.event(() => { - disposable.dispose(); - resolve(); - }); - }); - } - - endProgress() { - this._endProgress.fire(); - } -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; + +export class ProgressHelper { + private _progress: Promise = Promise.resolve(); + private _endProgress: vscode.EventEmitter = new vscode.EventEmitter(); + + get progress(): Promise { + return this._progress; + } + startProgress() { + this.endProgress(); + this._progress = new Promise(resolve => { + const disposable = this._endProgress.event(() => { + disposable.dispose(); + resolve(); + }); + }); + } + + endProgress() { + this._endProgress.fire(); + } +} diff --git a/src/view/prsTreeDataProvider.ts b/src/view/prsTreeDataProvider.ts index 6255bfebba..955b6d9fc6 100644 --- a/src/view/prsTreeDataProvider.ts +++ b/src/view/prsTreeDataProvider.ts @@ -1,315 +1,316 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { AuthProvider } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, QUERIES, REMOTES } from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { EXTENSION_ID } from '../constants'; -import { CredentialStore } from '../github/credentials'; -import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; -import { PRType } from '../github/interface'; -import { NotificationProvider } from '../github/notifications'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { findDotComAndEnterpriseRemotes } from '../github/utils'; -import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; -import { PrsTreeModel } from './prsTreeModel'; -import { ReviewModel } from './reviewModel'; -import { DecorationProvider } from './treeDecorationProvider'; -import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; -import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; -import { BaseTreeNode, EXPANDED_QUERIES_STATE, TreeNode } from './treeNodes/treeNode'; -import { TreeUtils } from './treeNodes/treeUtils'; -import { WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; - -export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider, BaseTreeNode, vscode.Disposable { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private _onDidChange = new vscode.EventEmitter(); - get onDidChange(): vscode.Event { - return this._onDidChange.event; - } - private _disposables: vscode.Disposable[]; - private _children: WorkspaceFolderNode[] | CategoryTreeNode[]; - get children() { - return this._children; - } - private _view: vscode.TreeView; - private _initialized: boolean = false; - public notificationProvider: NotificationProvider; - private _prsTreeModel: PrsTreeModel; - - get view(): vscode.TreeView { - return this._view; - } - - constructor(private _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager) { - this._disposables = []; - this._prsTreeModel = new PrsTreeModel(this._telemetry, this._reposManager); - this._disposables.push(this._prsTreeModel); - this._disposables.push(this._prsTreeModel.onDidChangeData(folderManager => folderManager ? this.refreshRepo(folderManager) : this.refresh())); - this._disposables.push(new PRStatusDecorationProvider(this._prsTreeModel)); - this._disposables.push(vscode.window.registerFileDecorationProvider(DecorationProvider)); - this._disposables.push( - vscode.commands.registerCommand('pr.refreshList', _ => { - this._prsTreeModel.clearCache(); - this._onDidChangeTreeData.fire(); - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { - node.fetchNextPage = true; - this._onDidChangeTreeData.fire(node); - }), - ); - - this._view = vscode.window.createTreeView('pr:github', { - treeDataProvider: this, - showCollapseAll: true, - }); - - this._disposables.push(this._view); - this._children = []; - - this._disposables.push( - vscode.commands.registerCommand('pr.configurePRViewlet', async () => { - const configuration = await vscode.window.showQuickPick([ - 'Configure Remotes...', - 'Configure Queries...' - ]); - - switch (configuration) { - case 'Configure Queries...': - return vscode.commands.executeCommand( - 'workbench.action.openSettings', - `@ext:${EXTENSION_ID} queries`, - ); - case 'Configure Remotes...': - return vscode.commands.executeCommand( - 'workbench.action.openSettings', - `@ext:${EXTENSION_ID} remotes`, - ); - default: - return; - } - }), - ); - - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { - this._onDidChangeTreeData.fire(); - } - }), - ); - - this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); - - this._disposables.push(this._view.onDidExpandElement(expanded => { - this._updateExpandedQueries(expanded.element, true); - })); - this._disposables.push(this._view.onDidCollapseElement(collapsed => { - this._updateExpandedQueries(collapsed.element, false); - })); - } - - private _updateExpandedQueries(element: TreeNode, isExpanded: boolean) { - if (element instanceof CategoryTreeNode) { - const expandedQueries = new Set(this._context.workspaceState.get(EXPANDED_QUERIES_STATE, []) as string[]); - if (isExpanded) { - expandedQueries.add(element.id); - } else { - expandedQueries.delete(element.id); - } - this._context.workspaceState.update(EXPANDED_QUERIES_STATE, Array.from(expandedQueries.keys())); - } - } - - public async expandPullRequest(pullRequest: PullRequestModel) { - if (this._children.length === 0) { - await this.getChildren(); - } - for (const child of this._children) { - if (child instanceof WorkspaceFolderNode) { - if (await child.expandPullRequest(pullRequest)) { - return; - } - } else if (child.type === PRType.All) { - if (await child.expandPullRequest(pullRequest)) { - return; - } - } - } - } - - async reveal(element: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean }): Promise { - return this._view.reveal(element, options); - } - - initialize(reviewModels: ReviewModel[], credentialStore: CredentialStore) { - if (this._initialized) { - throw new Error('Tree has already been initialized!'); - } - - this._initialized = true; - this._disposables.push( - this._reposManager.onDidChangeState(() => { - this.refresh(); - }), - ); - - this._disposables.push( - ...reviewModels.map(model => { - return model.onDidChangeLocalFileChanges(_ => { this.refresh(); }); - }), - ); - - this.notificationProvider = new NotificationProvider(this, credentialStore, this._reposManager); - this._disposables.push(this.notificationProvider); - - this.initializeCategories(); - this.refresh(); - } - - private async initializeCategories() { - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { - this.refresh(); - } - }), - ); - } - - refresh(node?: TreeNode): void { - return node ? this._onDidChangeTreeData.fire(node) : this._onDidChangeTreeData.fire(); - } - - private refreshRepo(manager: FolderRepositoryManager): void { - if (this._children.length === 0) { - return this.refresh(); - } - if (this._children[0] instanceof WorkspaceFolderNode) { - const children: WorkspaceFolderNode[] = this._children as WorkspaceFolderNode[]; - const node = children.find(node => node.folderManager === manager); - if (node) { - this._onDidChangeTreeData.fire(node); - return; - } - } - } - - getTreeItem(element: TreeNode): vscode.TreeItem | Promise { - return element.getTreeItem(); - } - - async resolveTreeItem(item: vscode.TreeItem, element: TreeNode): Promise { - if (element instanceof InMemFileChangeNode) { - await element.resolve(); - } - return element; - } - - private async needsRemotes() { - if (this._reposManager?.state === ReposManagerState.NeedsAuthentication) { - return []; - } - - const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); - let actions: PRCategoryActionNode[]; - if (remotesSetting) { - actions = [ - new PRCategoryActionNode(this, PRCategoryActionType.NoMatchingRemotes), - new PRCategoryActionNode(this, PRCategoryActionType.ConfigureRemotes), - - ]; - } else { - actions = [new PRCategoryActionNode(this, PRCategoryActionType.NoRemotes)]; - } - - const { enterpriseRemotes } = this._reposManager ? await findDotComAndEnterpriseRemotes(this._reposManager?.folderManagers) : { enterpriseRemotes: [] }; - if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { - actions.push(new PRCategoryActionNode(this, PRCategoryActionType.LoginEnterprise)); - } - - return actions; - } - - async cachedChildren(element?: WorkspaceFolderNode | CategoryTreeNode): Promise { - if (!element) { - return this._children; - } - return element.cachedChildren(); - } - - async getChildren(element?: TreeNode): Promise { - if (!this._reposManager?.folderManagers.length) { - return []; - } - - if (this._reposManager.state === ReposManagerState.Initializing) { - commands.setContext(contexts.LOADING_PRS_TREE, true); - return []; - } - - const remotes = await Promise.all(this._reposManager.folderManagers.map(manager => manager.getGitHubRemotes())); - if ((this._reposManager.folderManagers.filter((_manager, index) => remotes[index].length > 0).length === 0)) { - return this.needsRemotes(); - } - - if (!element) { - if (this._children && this._children.length) { - this._children.forEach(dispose => dispose.dispose()); - } - - let result: WorkspaceFolderNode[] | CategoryTreeNode[]; - if (this._reposManager.folderManagers.length === 1) { - result = WorkspaceFolderNode.getCategoryTreeNodes( - this._reposManager.folderManagers[0], - this._telemetry, - this, - this.notificationProvider, - this._context, - this._prsTreeModel - ); - } else { - result = this._reposManager.folderManagers.map( - folderManager => - new WorkspaceFolderNode( - this, - folderManager.repository.rootUri, - folderManager, - this._telemetry, - this.notificationProvider, - this._context, - this._prsTreeModel - ), - ); - } - - this._children = result; - return result; - } - - if ( - this._reposManager.folderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === 0 - ) { - return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.Empty)]); - } - - return element.getChildren(); - } - - async getParent(element: TreeNode): Promise { - return element.getParent(); - } - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { AuthProvider } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, QUERIES, REMOTES } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { EXTENSION_ID } from '../constants'; +import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { PRType } from '../github/interface'; +import { NotificationProvider } from '../github/notifications'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { findDotComAndEnterpriseRemotes } from '../github/utils'; +import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; +import { PrsTreeModel } from './prsTreeModel'; +import { ReviewModel } from './reviewModel'; +import { DecorationProvider } from './treeDecorationProvider'; +import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; +import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; +import { BaseTreeNode, EXPANDED_QUERIES_STATE, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; +import { WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; + +export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider, BaseTreeNode, vscode.Disposable { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _onDidChange = new vscode.EventEmitter(); + get onDidChange(): vscode.Event { + return this._onDidChange.event; + } + private _disposables: vscode.Disposable[]; + private _children: WorkspaceFolderNode[] | CategoryTreeNode[]; + get children() { + return this._children; + } + private _view: vscode.TreeView; + private _initialized: boolean = false; + public notificationProvider: NotificationProvider; + private _prsTreeModel: PrsTreeModel; + + get view(): vscode.TreeView { + return this._view; + } + + constructor(private _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager) { + this._disposables = []; + this._prsTreeModel = new PrsTreeModel(this._telemetry, this._reposManager); + this._disposables.push(this._prsTreeModel); + this._disposables.push(this._prsTreeModel.onDidChangeData(folderManager => folderManager ? this.refreshRepo(folderManager) : this.refresh())); + this._disposables.push(new PRStatusDecorationProvider(this._prsTreeModel)); + this._disposables.push(vscode.window.registerFileDecorationProvider(DecorationProvider)); + this._disposables.push( + vscode.commands.registerCommand('pr.refreshList', _ => { + this._prsTreeModel.clearCache(); + this._onDidChangeTreeData.fire(); + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { + node.fetchNextPage = true; + this._onDidChangeTreeData.fire(node); + }), + ); + + this._view = vscode.window.createTreeView('pr:github', { + treeDataProvider: this, + showCollapseAll: true, + }); + + this._disposables.push(this._view); + this._children = []; + + this._disposables.push( + vscode.commands.registerCommand('pr.configurePRViewlet', async () => { + const configuration = await vscode.window.showQuickPick([ + 'Configure Remotes...', + 'Configure Queries...' + ]); + + switch (configuration) { + case 'Configure Queries...': + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} queries`, + ); + case 'Configure Remotes...': + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} remotes`, + ); + default: + return; + } + }), + ); + + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + this._onDidChangeTreeData.fire(); + } + }), + ); + + this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); + + this._disposables.push(this._view.onDidExpandElement(expanded => { + this._updateExpandedQueries(expanded.element, true); + })); + this._disposables.push(this._view.onDidCollapseElement(collapsed => { + this._updateExpandedQueries(collapsed.element, false); + })); + } + + private _updateExpandedQueries(element: TreeNode, isExpanded: boolean) { + if (element instanceof CategoryTreeNode) { + const expandedQueries = new Set(this._context.workspaceState.get(EXPANDED_QUERIES_STATE, []) as string[]); + if (isExpanded) { + expandedQueries.add(element.id); + } else { + expandedQueries.delete(element.id); + } + this._context.workspaceState.update(EXPANDED_QUERIES_STATE, Array.from(expandedQueries.keys())); + } + } + + public async expandPullRequest(pullRequest: PullRequestModel) { + if (this._children.length === 0) { + await this.getChildren(); + } + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + if (await child.expandPullRequest(pullRequest)) { + return; + } + } else if (child.type === PRType.All) { + if (await child.expandPullRequest(pullRequest)) { + return; + } + } + } + } + + async reveal(element: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean }): Promise { + return this._view.reveal(element, options); + } + + initialize(reviewModels: ReviewModel[], credentialStore: CredentialStore) { + if (this._initialized) { + throw new Error('Tree has already been initialized!'); + } + + this._initialized = true; + this._disposables.push( + this._reposManager.onDidChangeState(() => { + this.refresh(); + }), + ); + + this._disposables.push( + ...reviewModels.map(model => { + return model.onDidChangeLocalFileChanges(_ => { this.refresh(); }); + }), + ); + + this.notificationProvider = new NotificationProvider(this, credentialStore, this._reposManager); + this._disposables.push(this.notificationProvider); + + this.initializeCategories(); + this.refresh(); + } + + private async initializeCategories() { + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this.refresh(); + } + }), + ); + } + + refresh(node?: TreeNode): void { + return node ? this._onDidChangeTreeData.fire(node) : this._onDidChangeTreeData.fire(); + } + + private refreshRepo(manager: FolderRepositoryManager): void { + if (this._children.length === 0) { + return this.refresh(); + } + if (this._children[0] instanceof WorkspaceFolderNode) { + const children: WorkspaceFolderNode[] = this._children as WorkspaceFolderNode[]; + const node = children.find(node => node.folderManager === manager); + if (node) { + this._onDidChangeTreeData.fire(node); + return; + } + } + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Promise { + return element.getTreeItem(); + } + + async resolveTreeItem(item: vscode.TreeItem, element: TreeNode): Promise { + if (element instanceof InMemFileChangeNode) { + await element.resolve(); + } + return element; + } + + private async needsRemotes() { + if (this._reposManager?.state === ReposManagerState.NeedsAuthentication) { + return []; + } + + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + let actions: PRCategoryActionNode[]; + if (remotesSetting) { + actions = [ + new PRCategoryActionNode(this, PRCategoryActionType.NoMatchingRemotes), + new PRCategoryActionNode(this, PRCategoryActionType.ConfigureRemotes), + + ]; + } else { + actions = [new PRCategoryActionNode(this, PRCategoryActionType.NoRemotes)]; + } + + const { enterpriseRemotes } = this._reposManager ? await findDotComAndEnterpriseRemotes(this._reposManager?.folderManagers) : { enterpriseRemotes: [] }; + if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + actions.push(new PRCategoryActionNode(this, PRCategoryActionType.LoginEnterprise)); + } + + return actions; + } + + async cachedChildren(element?: WorkspaceFolderNode | CategoryTreeNode): Promise { + if (!element) { + return this._children; + } + return element.cachedChildren(); + } + + async getChildren(element?: TreeNode): Promise { + if (!this._reposManager?.folderManagers.length) { + return []; + } + + if (this._reposManager.state === ReposManagerState.Initializing) { + commands.setContext(contexts.LOADING_PRS_TREE, true); + return []; + } + + const remotes = await Promise.all(this._reposManager.folderManagers.map(manager => manager.getGitHubRemotes())); + if ((this._reposManager.folderManagers.filter((_manager, index) => remotes[index].length > 0).length === 0)) { + return this.needsRemotes(); + } + + if (!element) { + if (this._children && this._children.length) { + this._children.forEach(dispose => dispose.dispose()); + } + + let result: WorkspaceFolderNode[] | CategoryTreeNode[]; + if (this._reposManager.folderManagers.length === 1) { + result = WorkspaceFolderNode.getCategoryTreeNodes( + this._reposManager.folderManagers[0], + this._telemetry, + this, + this.notificationProvider, + this._context, + this._prsTreeModel + ); + } else { + result = this._reposManager.folderManagers.map( + folderManager => + new WorkspaceFolderNode( + this, + folderManager.repository.rootUri, + folderManager, + this._telemetry, + this.notificationProvider, + this._context, + this._prsTreeModel + ), + ); + } + + this._children = result; + return result; + } + + if ( + this._reposManager.folderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === 0 + ) { + return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.Empty)]); + } + + return element.getChildren(); + } + + async getParent(element: TreeNode): Promise { + return element.getParent(); + } + + dispose() { + this._disposables.forEach(dispose => dispose.dispose()); + } +} diff --git a/src/view/prsTreeModel.ts b/src/view/prsTreeModel.ts index 9e90c18393..0ecbc80593 100644 --- a/src/view/prsTreeModel.ts +++ b/src/view/prsTreeModel.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { PR_SETTINGS_NAMESPACE, USE_REVIEW_MODE } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; @@ -215,4 +216,4 @@ export class PrsTreeModel implements vscode.Disposable { dispose(Array.from(this._activePRDisposables.values()).flat()); } -} \ No newline at end of file +} diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index 205d03afc8..3a067a0eff 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -1,554 +1,555 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import { v4 as uuid } from 'uuid'; -import * as vscode from 'vscode'; -import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IComment, SubjectType } from '../common/comment'; -import { fromPRUri, Schemes } from '../common/uri'; -import { groupBy } from '../common/utils'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; -import { PullRequestModel, ReviewThreadChangeEvent } from '../github/pullRequestModel'; -import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; -import { - CommentReactionHandler, - createVSCodeCommentThreadForReviewThread, - threadRange, - updateCommentReviewState, - updateCommentThreadLabel, - updateThread, - updateThreadWithRange, -} from '../github/utils'; - -export class PullRequestCommentController implements CommentHandler, CommentReactionHandler { - private _pendingCommentThreadAdds: GHPRCommentThread[] = []; - private _commentHandlerId: string; - private _commentThreadCache: { [key: string]: GHPRCommentThread[] } = {}; - private _openPREditors: vscode.TextEditor[] = []; - /** - * Cached threads belong to editors that are closed, but that we keep cached because they were recently used. - * This prevents comment replies that haven't been submitted from getting deleted too easily. - */ - private _closedEditorCachedThreads: Set = new Set(); - private _disposables: vscode.Disposable[] = []; - private readonly _context: vscode.ExtensionContext; - - constructor( - private pullRequestModel: PullRequestModel, - private _folderReposManager: FolderRepositoryManager, - private _commentController: vscode.CommentController, - ) { - this._context = _folderReposManager.context; - this._commentHandlerId = uuid(); - registerCommentHandler(this._commentHandlerId, this); - - if (this.pullRequestModel.reviewThreadsCacheReady) { - this.initializeThreadsInOpenEditors().then(() => { - this.registerListeners(); - }); - } else { - const reviewThreadsDisposable = this.pullRequestModel.onDidChangeReviewThreads(async () => { - reviewThreadsDisposable.dispose(); - await this.initializeThreadsInOpenEditors(); - this.registerListeners(); - }); - } - } - - private registerListeners(): void { - this._disposables.push(this.pullRequestModel.onDidChangeReviewThreads(e => this.onDidChangeReviewThreads(e))); - - this._disposables.push( - vscode.window.onDidChangeVisibleTextEditors(async e => { - return this.onDidChangeOpenEditors(e); - }), - ); - - this._disposables.push( - this.pullRequestModel.onDidChangePendingReviewState(newDraftMode => { - for (const key in this._commentThreadCache) { - this._commentThreadCache[key].forEach(thread => { - updateCommentReviewState(thread, newDraftMode); - }); - } - }), - ); - - this._disposables.push( - vscode.window.onDidChangeActiveTextEditor(e => { - this.refreshContextKey(e); - }), - ); - } - - private refreshContextKey(editor: vscode.TextEditor | undefined): void { - if (!editor) { - return; - } - - const editorUri = editor.document.uri; - if (editorUri.scheme !== Schemes.Pr) { - return; - } - - const params = fromPRUri(editorUri); - if (!params || params.prNumber !== this.pullRequestModel.number) { - return; - } - - this.setContextKey(this.pullRequestModel.hasPendingReview); - } - - private getPREditors(editors: readonly vscode.TextEditor[]): vscode.TextEditor[] { - return editors.filter(editor => { - if (editor.document.uri.scheme !== Schemes.Pr) { - return false; - } - - const params = fromPRUri(editor.document.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return false; - } - - return true; - }); - } - - private getCommentThreadCacheKey(fileName: string, isBase: boolean): string { - return `${fileName}-${isBase ? 'original' : 'modified'}`; - } - - private tryUsedCachedEditor(editors: vscode.TextEditor[]): vscode.TextEditor[] { - const uncachedEditors: vscode.TextEditor[] = []; - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; - const key = this.getCommentThreadCacheKey(fileName, isBase); - if (this._closedEditorCachedThreads.has(key)) { - // Update position in cache - this._closedEditorCachedThreads.delete(key); - this._closedEditorCachedThreads.add(key); - } else { - uncachedEditors.push(editor); - } - }); - return uncachedEditors; - } - - private async addThreadsForEditors(newEditors: vscode.TextEditor[]): Promise { - const editors = this.tryUsedCachedEditor(newEditors); - const reviewThreads = this.pullRequestModel.reviewThreadsCache; - const threadsByPath = groupBy(reviewThreads, thread => thread.path); - const currentUser = await this._folderReposManager.getCurrentUser(); - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; - if (threadsByPath[fileName]) { - this._commentThreadCache[this.getCommentThreadCacheKey(fileName, isBase)] = threadsByPath[fileName] - .filter( - thread => - ((thread.diffSide === DiffSide.LEFT && isBase) || - (thread.diffSide === DiffSide.RIGHT && !isBase)) - && (thread.endLine !== null), - ) - .map(thread => { - const endLine = thread.endLine - 1; - const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, editor.document.lineAt(endLine).range.end.character); - - return createVSCodeCommentThreadForReviewThread( - this._context, - editor.document.uri, - range, - thread, - this._commentController, - currentUser.login, - this.pullRequestModel.githubRepository - ); - }); - } - }); - } - - private async initializeThreadsInOpenEditors(): Promise { - const prEditors = this.getPREditors(vscode.window.visibleTextEditors); - this._openPREditors = prEditors; - return this.addThreadsForEditors(prEditors); - } - - private cleanCachedEditors() { - // Keep the most recent 8 editors (4 diffs) around and clean up the rest. - if (this._closedEditorCachedThreads.size > 8) { - const keys = Array.from(this._closedEditorCachedThreads.keys()); - for (let i = 0; i < this._closedEditorCachedThreads.size - 4; i++) { - const key = keys[i]; - this.cleanCachedEditor(key); - this._closedEditorCachedThreads.delete(key); - } - } - } - - private cleanCachedEditor(key: string) { - const threads = this._commentThreadCache[key] || []; - threads.forEach(t => t.dispose()); - delete this._commentThreadCache[key]; - } - - private addCachedEditors(editors: vscode.TextEditor[]) { - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; - const key = this.getCommentThreadCacheKey(fileName, isBase); - if (this._closedEditorCachedThreads.has(key)) { - // Delete to update position in the cache - this._closedEditorCachedThreads.delete(key); - } - this._closedEditorCachedThreads.add(key); - }); - } - - private async onDidChangeOpenEditors(editors: readonly vscode.TextEditor[]): Promise { - const prEditors = this.getPREditors(editors); - const removed = this._openPREditors.filter(x => !prEditors.includes(x)); - this.addCachedEditors(removed); - this.cleanCachedEditors(); - - const added = prEditors.filter(x => !this._openPREditors.includes(x)); - this._openPREditors = prEditors; - if (added.length) { - await this.addThreadsForEditors(added); - } - } - - private onDidChangeReviewThreads(e: ReviewThreadChangeEvent): void { - e.added.forEach(async (thread) => { - const fileName = thread.path; - const index = this._pendingCommentThreadAdds.findIndex(t => { - const samePath = this.gitRelativeRootPath(t.uri.path) === thread.path; - const sameLine = (t.range === undefined && thread.subjectType === SubjectType.FILE) || (t.range && t.range.end.line + 1 === thread.endLine); - return samePath && sameLine; - }); - - let newThread: GHPRCommentThread | undefined = undefined; - if (index > -1) { - newThread = this._pendingCommentThreadAdds[index]; - newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread!, this.pullRequestModel.githubRepository)); - updateThreadWithRange(this._context, newThread, thread, this.pullRequestModel.githubRepository); - this._pendingCommentThreadAdds.splice(index, 1); - } else { - const openPREditors = this.getPREditors(vscode.window.visibleTextEditors); - const matchingEditor = openPREditors.find(editor => { - const query = fromPRUri(editor.document.uri); - const sameSide = - (thread.diffSide === DiffSide.RIGHT && !query?.isBase) || - (thread.diffSide === DiffSide.LEFT && query?.isBase); - return query?.fileName === fileName && sameSide; - }); - - if (matchingEditor) { - const endLine = thread.endLine - 1; - const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, matchingEditor.document.lineAt(endLine).range.end.character); - - newThread = createVSCodeCommentThreadForReviewThread( - this._context, - matchingEditor.document.uri, - range, - thread, - this._commentController, - (await this._folderReposManager.getCurrentUser()).login, - this.pullRequestModel.githubRepository - ); - } - } - - if (!newThread) { - return; - } - const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); - if (this._commentThreadCache[key]) { - this._commentThreadCache[key].push(newThread); - } else { - this._commentThreadCache[key] = [newThread]; - } - }); - - e.changed.forEach(thread => { - const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); - const index = this._commentThreadCache[key] ? this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id) : -1; - if (index > -1) { - const matchingThread = this._commentThreadCache[key][index]; - updateThread(this._context, matchingThread, thread, this.pullRequestModel.githubRepository); - } - }); - - e.removed.forEach(async thread => { - const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); - const index = this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id); - if (index > -1) { - const matchingThread = this._commentThreadCache[key][index]; - this._commentThreadCache[key].splice(index, 1); - matchingThread.dispose(); - } - }); - } - - hasCommentThread(thread: GHPRCommentThread): boolean { - if (thread.uri.scheme !== Schemes.Pr) { - return false; - } - - const params = fromPRUri(thread.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return false; - } - - return true; - } - - private getCommentSide(thread: GHPRCommentThread): DiffSide { - const query = fromPRUri(thread.uri); - return query?.isBase ? DiffSide.LEFT : DiffSide.RIGHT; - } - - public async createOrReplyComment( - thread: GHPRCommentThread, - input: string, - isSingleComment: boolean, - inDraft?: boolean, - ): Promise { - const hasExistingComments = thread.comments.length; - const isDraft = isSingleComment - ? false - : inDraft !== undefined - ? inDraft - : this.pullRequestModel.hasPendingReview; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); - - try { - if (hasExistingComments) { - await this.reply(thread, input, isSingleComment); - } else { - const fileName = this.gitRelativeRootPath(thread.uri.path); - const side = this.getCommentSide(thread); - this._pendingCommentThreadAdds.push(thread); - await this.pullRequestModel.createReviewThread( - input, - fileName, - thread.range ? (thread.range.start.line + 1) : undefined, - thread.range ? (thread.range.end.line + 1) : undefined, - side, - isSingleComment, - ); - } - - if (isSingleComment) { - await this.pullRequestModel.submitReview(); - } - } catch (e) { - if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { - vscode.window.showWarningMessage('The comment that you\'re replying to was deleted. Refresh to update.', 'Refresh').then(result => { - if (result === 'Refresh') { - this.pullRequestModel.invalidate(); - } - }); - } else { - vscode.window.showErrorMessage(`Creating comment failed: ${e}`); - } - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - private reply(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise { - const replyingTo = thread.comments[0]; - if (replyingTo instanceof GHPRComment) { - return this.pullRequestModel.createCommentReply(input, replyingTo.rawComment.graphNodeId, isSingleComment); - } else { - // TODO can we do better? - throw new Error('Cannot respond to temporary comment'); - } - } - - private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { - const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); - const temporaryComment = new TemporaryComment( - thread, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - !!comment.label, - currentUser, - comment, - ); - thread.comments = thread.comments.map(c => { - if (c instanceof GHPRComment && c.commentId === comment.commentId) { - return temporaryComment; - } - - return c; - }); - - return temporaryComment.id; - } - - public async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { - if (comment instanceof GHPRComment) { - const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); - try { - await this.pullRequestModel.editReviewComment( - comment.rawComment, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - ); - } catch (e) { - vscode.window.showErrorMessage(`Editing comment failed ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(this._context, comment.rawComment, thread); - } - - return c; - }); - } - } else { - this.createOrReplyComment( - thread, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - false, - ); - } - } - - public async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { - if (comment instanceof GHPRComment) { - await this.pullRequestModel.deleteReviewComment(comment.commentId); - } else { - thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); - } - - await this.pullRequestModel.validateDraftMode(); - } - // #endregion - - private gitRelativeRootPath(comparePath: string) { - // get path relative to git root directory. Handles windows path by converting it to unix path. - return path.relative(this._folderReposManager.repository.rootUri.path, comparePath).replace(/\\/g, '/'); - } - - // #region Review - public async startReview(thread: GHPRCommentThread, input: string): Promise { - const hasExistingComments = thread.comments.length; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); - - try { - if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); - const side = this.getCommentSide(thread); - this._pendingCommentThreadAdds.push(thread); - await this.pullRequestModel.createReviewThread(input, fileName, thread.range ? (thread.range.start.line + 1) : undefined, thread.range ? (thread.range.end.line + 1) : undefined, side); - } else { - await this.reply(thread, input, false); - } - - this.setContextKey(true); - } catch (e) { - vscode.window.showErrorMessage(`Starting a review failed: ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - - public async openReview(): Promise { - await PullRequestOverviewPanel.createOrShow(this._folderReposManager.context.extensionUri, this._folderReposManager, this.pullRequestModel); - PullRequestOverviewPanel.scrollToReview(); - - /* __GDPR__ - "pr.openDescription" : {} - */ - this._folderReposManager.telemetry.sendTelemetryEvent('pr.openDescription'); - } - - private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { - const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); - const comment = new TemporaryComment(thread, input, inDraft, currentUser); - this.updateCommentThreadComments(thread, [...thread.comments, comment]); - return comment.id; - } - - private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { - thread.comments = newComments; - updateCommentThreadLabel(thread); - } - - private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise { - const pendingReviewId = await this.pullRequestModel.getPendingReviewId(); - await this.createOrReplyComment(thread, input, !pendingReviewId); - } - - public async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { - try { - if (input) { - await this.createCommentOnResolve(thread, input); - } - - await this.pullRequestModel.resolveReviewThread(thread.gitHubThreadId); - } catch (e) { - vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); - } - } - - public async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { - try { - if (input) { - await this.createCommentOnResolve(thread, input); - } - - await this.pullRequestModel.unresolveReviewThread(thread.gitHubThreadId); - } catch (e) { - vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); - } - } - - public async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { - if (comment.parent!.uri.scheme !== Schemes.Pr) { - return; - } - - if ( - comment.reactions && - !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) - ) { - // add reaction - await this.pullRequestModel.addCommentReaction(comment.rawComment.graphNodeId, reaction); - } else { - await this.pullRequestModel.deleteCommentReaction(comment.rawComment.graphNodeId, reaction); - } - } - - private setContextKey(inDraftMode: boolean): void { - vscode.commands.executeCommand('setContext', 'prInDraft', inDraftMode); - } - - dispose() { - Object.keys(this._commentThreadCache).forEach(key => { - this._commentThreadCache[key].forEach(thread => thread.dispose()); - }); - - unregisterCommentHandler(this._commentHandlerId); - - this._disposables.forEach(d => d.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import { v4 as uuid } from 'uuid'; +import * as vscode from 'vscode'; +import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; +import { DiffSide, IComment, SubjectType } from '../common/comment'; +import { fromPRUri, Schemes } from '../common/uri'; +import { groupBy } from '../common/utils'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; +import { PullRequestModel, ReviewThreadChangeEvent } from '../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; +import { + CommentReactionHandler, + createVSCodeCommentThreadForReviewThread, + threadRange, + updateCommentReviewState, + updateCommentThreadLabel, + updateThread, + updateThreadWithRange, +} from '../github/utils'; + +export class PullRequestCommentController implements CommentHandler, CommentReactionHandler { + private _pendingCommentThreadAdds: GHPRCommentThread[] = []; + private _commentHandlerId: string; + private _commentThreadCache: { [key: string]: GHPRCommentThread[] } = {}; + private _openPREditors: vscode.TextEditor[] = []; + /** + * Cached threads belong to editors that are closed, but that we keep cached because they were recently used. + * This prevents comment replies that haven't been submitted from getting deleted too easily. + */ + private _closedEditorCachedThreads: Set = new Set(); + private _disposables: vscode.Disposable[] = []; + private readonly _context: vscode.ExtensionContext; + + constructor( + private pullRequestModel: PullRequestModel, + private _folderReposManager: FolderRepositoryManager, + private _commentController: vscode.CommentController, + ) { + this._context = _folderReposManager.context; + this._commentHandlerId = uuid(); + registerCommentHandler(this._commentHandlerId, this); + + if (this.pullRequestModel.reviewThreadsCacheReady) { + this.initializeThreadsInOpenEditors().then(() => { + this.registerListeners(); + }); + } else { + const reviewThreadsDisposable = this.pullRequestModel.onDidChangeReviewThreads(async () => { + reviewThreadsDisposable.dispose(); + await this.initializeThreadsInOpenEditors(); + this.registerListeners(); + }); + } + } + + private registerListeners(): void { + this._disposables.push(this.pullRequestModel.onDidChangeReviewThreads(e => this.onDidChangeReviewThreads(e))); + + this._disposables.push( + vscode.window.onDidChangeVisibleTextEditors(async e => { + return this.onDidChangeOpenEditors(e); + }), + ); + + this._disposables.push( + this.pullRequestModel.onDidChangePendingReviewState(newDraftMode => { + for (const key in this._commentThreadCache) { + this._commentThreadCache[key].forEach(thread => { + updateCommentReviewState(thread, newDraftMode); + }); + } + }), + ); + + this._disposables.push( + vscode.window.onDidChangeActiveTextEditor(e => { + this.refreshContextKey(e); + }), + ); + } + + private refreshContextKey(editor: vscode.TextEditor | undefined): void { + if (!editor) { + return; + } + + const editorUri = editor.document.uri; + if (editorUri.scheme !== Schemes.Pr) { + return; + } + + const params = fromPRUri(editorUri); + if (!params || params.prNumber !== this.pullRequestModel.number) { + return; + } + + this.setContextKey(this.pullRequestModel.hasPendingReview); + } + + private getPREditors(editors: readonly vscode.TextEditor[]): vscode.TextEditor[] { + return editors.filter(editor => { + if (editor.document.uri.scheme !== Schemes.Pr) { + return false; + } + + const params = fromPRUri(editor.document.uri); + + if (!params || params.prNumber !== this.pullRequestModel.number) { + return false; + } + + return true; + }); + } + + private getCommentThreadCacheKey(fileName: string, isBase: boolean): string { + return `${fileName}-${isBase ? 'original' : 'modified'}`; + } + + private tryUsedCachedEditor(editors: vscode.TextEditor[]): vscode.TextEditor[] { + const uncachedEditors: vscode.TextEditor[] = []; + editors.forEach(editor => { + const { fileName, isBase } = fromPRUri(editor.document.uri)!; + const key = this.getCommentThreadCacheKey(fileName, isBase); + if (this._closedEditorCachedThreads.has(key)) { + // Update position in cache + this._closedEditorCachedThreads.delete(key); + this._closedEditorCachedThreads.add(key); + } else { + uncachedEditors.push(editor); + } + }); + return uncachedEditors; + } + + private async addThreadsForEditors(newEditors: vscode.TextEditor[]): Promise { + const editors = this.tryUsedCachedEditor(newEditors); + const reviewThreads = this.pullRequestModel.reviewThreadsCache; + const threadsByPath = groupBy(reviewThreads, thread => thread.path); + const currentUser = await this._folderReposManager.getCurrentUser(); + editors.forEach(editor => { + const { fileName, isBase } = fromPRUri(editor.document.uri)!; + if (threadsByPath[fileName]) { + this._commentThreadCache[this.getCommentThreadCacheKey(fileName, isBase)] = threadsByPath[fileName] + .filter( + thread => + ((thread.diffSide === DiffSide.LEFT && isBase) || + (thread.diffSide === DiffSide.RIGHT && !isBase)) + && (thread.endLine !== null), + ) + .map(thread => { + const endLine = thread.endLine - 1; + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, editor.document.lineAt(endLine).range.end.character); + + return createVSCodeCommentThreadForReviewThread( + this._context, + editor.document.uri, + range, + thread, + this._commentController, + currentUser.login, + this.pullRequestModel.githubRepository + ); + }); + } + }); + } + + private async initializeThreadsInOpenEditors(): Promise { + const prEditors = this.getPREditors(vscode.window.visibleTextEditors); + this._openPREditors = prEditors; + return this.addThreadsForEditors(prEditors); + } + + private cleanCachedEditors() { + // Keep the most recent 8 editors (4 diffs) around and clean up the rest. + if (this._closedEditorCachedThreads.size > 8) { + const keys = Array.from(this._closedEditorCachedThreads.keys()); + for (let i = 0; i < this._closedEditorCachedThreads.size - 4; i++) { + const key = keys[i]; + this.cleanCachedEditor(key); + this._closedEditorCachedThreads.delete(key); + } + } + } + + private cleanCachedEditor(key: string) { + const threads = this._commentThreadCache[key] || []; + threads.forEach(t => t.dispose()); + delete this._commentThreadCache[key]; + } + + private addCachedEditors(editors: vscode.TextEditor[]) { + editors.forEach(editor => { + const { fileName, isBase } = fromPRUri(editor.document.uri)!; + const key = this.getCommentThreadCacheKey(fileName, isBase); + if (this._closedEditorCachedThreads.has(key)) { + // Delete to update position in the cache + this._closedEditorCachedThreads.delete(key); + } + this._closedEditorCachedThreads.add(key); + }); + } + + private async onDidChangeOpenEditors(editors: readonly vscode.TextEditor[]): Promise { + const prEditors = this.getPREditors(editors); + const removed = this._openPREditors.filter(x => !prEditors.includes(x)); + this.addCachedEditors(removed); + this.cleanCachedEditors(); + + const added = prEditors.filter(x => !this._openPREditors.includes(x)); + this._openPREditors = prEditors; + if (added.length) { + await this.addThreadsForEditors(added); + } + } + + private onDidChangeReviewThreads(e: ReviewThreadChangeEvent): void { + e.added.forEach(async (thread) => { + const fileName = thread.path; + const index = this._pendingCommentThreadAdds.findIndex(t => { + const samePath = this.gitRelativeRootPath(t.uri.path) === thread.path; + const sameLine = (t.range === undefined && thread.subjectType === SubjectType.FILE) || (t.range && t.range.end.line + 1 === thread.endLine); + return samePath && sameLine; + }); + + let newThread: GHPRCommentThread | undefined = undefined; + if (index > -1) { + newThread = this._pendingCommentThreadAdds[index]; + newThread.gitHubThreadId = thread.id; + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread!, this.pullRequestModel.githubRepository)); + updateThreadWithRange(this._context, newThread, thread, this.pullRequestModel.githubRepository); + this._pendingCommentThreadAdds.splice(index, 1); + } else { + const openPREditors = this.getPREditors(vscode.window.visibleTextEditors); + const matchingEditor = openPREditors.find(editor => { + const query = fromPRUri(editor.document.uri); + const sameSide = + (thread.diffSide === DiffSide.RIGHT && !query?.isBase) || + (thread.diffSide === DiffSide.LEFT && query?.isBase); + return query?.fileName === fileName && sameSide; + }); + + if (matchingEditor) { + const endLine = thread.endLine - 1; + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, matchingEditor.document.lineAt(endLine).range.end.character); + + newThread = createVSCodeCommentThreadForReviewThread( + this._context, + matchingEditor.document.uri, + range, + thread, + this._commentController, + (await this._folderReposManager.getCurrentUser()).login, + this.pullRequestModel.githubRepository + ); + } + } + + if (!newThread) { + return; + } + const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); + if (this._commentThreadCache[key]) { + this._commentThreadCache[key].push(newThread); + } else { + this._commentThreadCache[key] = [newThread]; + } + }); + + e.changed.forEach(thread => { + const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); + const index = this._commentThreadCache[key] ? this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id) : -1; + if (index > -1) { + const matchingThread = this._commentThreadCache[key][index]; + updateThread(this._context, matchingThread, thread, this.pullRequestModel.githubRepository); + } + }); + + e.removed.forEach(async thread => { + const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); + const index = this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id); + if (index > -1) { + const matchingThread = this._commentThreadCache[key][index]; + this._commentThreadCache[key].splice(index, 1); + matchingThread.dispose(); + } + }); + } + + hasCommentThread(thread: GHPRCommentThread): boolean { + if (thread.uri.scheme !== Schemes.Pr) { + return false; + } + + const params = fromPRUri(thread.uri); + + if (!params || params.prNumber !== this.pullRequestModel.number) { + return false; + } + + return true; + } + + private getCommentSide(thread: GHPRCommentThread): DiffSide { + const query = fromPRUri(thread.uri); + return query?.isBase ? DiffSide.LEFT : DiffSide.RIGHT; + } + + public async createOrReplyComment( + thread: GHPRCommentThread, + input: string, + isSingleComment: boolean, + inDraft?: boolean, + ): Promise { + const hasExistingComments = thread.comments.length; + const isDraft = isSingleComment + ? false + : inDraft !== undefined + ? inDraft + : this.pullRequestModel.hasPendingReview; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); + + try { + if (hasExistingComments) { + await this.reply(thread, input, isSingleComment); + } else { + const fileName = this.gitRelativeRootPath(thread.uri.path); + const side = this.getCommentSide(thread); + this._pendingCommentThreadAdds.push(thread); + await this.pullRequestModel.createReviewThread( + input, + fileName, + thread.range ? (thread.range.start.line + 1) : undefined, + thread.range ? (thread.range.end.line + 1) : undefined, + side, + isSingleComment, + ); + } + + if (isSingleComment) { + await this.pullRequestModel.submitReview(); + } + } catch (e) { + if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { + vscode.window.showWarningMessage('The comment that you\'re replying to was deleted. Refresh to update.', 'Refresh').then(result => { + if (result === 'Refresh') { + this.pullRequestModel.invalidate(); + } + }); + } else { + vscode.window.showErrorMessage(`Creating comment failed: ${e}`); + } + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + private reply(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise { + const replyingTo = thread.comments[0]; + if (replyingTo instanceof GHPRComment) { + return this.pullRequestModel.createCommentReply(input, replyingTo.rawComment.graphNodeId, isSingleComment); + } else { + // TODO can we do better? + throw new Error('Cannot respond to temporary comment'); + } + } + + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { + const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); + const temporaryComment = new TemporaryComment( + thread, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + !!comment.label, + currentUser, + comment, + ); + thread.comments = thread.comments.map(c => { + if (c instanceof GHPRComment && c.commentId === comment.commentId) { + return temporaryComment; + } + + return c; + }); + + return temporaryComment.id; + } + + public async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { + if (comment instanceof GHPRComment) { + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); + try { + await this.pullRequestModel.editReviewComment( + comment.rawComment, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + ); + } catch (e) { + vscode.window.showErrorMessage(`Editing comment failed ${e}`); + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + return new GHPRComment(this._context, comment.rawComment, thread); + } + + return c; + }); + } + } else { + this.createOrReplyComment( + thread, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + false, + ); + } + } + + public async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { + if (comment instanceof GHPRComment) { + await this.pullRequestModel.deleteReviewComment(comment.commentId); + } else { + thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); + } + + await this.pullRequestModel.validateDraftMode(); + } + // #endregion + + private gitRelativeRootPath(comparePath: string) { + // get path relative to git root directory. Handles windows path by converting it to unix path. + return path.relative(this._folderReposManager.repository.rootUri.path, comparePath).replace(/\\/g, '/'); + } + + // #region Review + public async startReview(thread: GHPRCommentThread, input: string): Promise { + const hasExistingComments = thread.comments.length; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); + + try { + if (!hasExistingComments) { + const fileName = this.gitRelativeRootPath(thread.uri.path); + const side = this.getCommentSide(thread); + this._pendingCommentThreadAdds.push(thread); + await this.pullRequestModel.createReviewThread(input, fileName, thread.range ? (thread.range.start.line + 1) : undefined, thread.range ? (thread.range.end.line + 1) : undefined, side); + } else { + await this.reply(thread, input, false); + } + + this.setContextKey(true); + } catch (e) { + vscode.window.showErrorMessage(`Starting a review failed: ${e}`); + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + + public async openReview(): Promise { + await PullRequestOverviewPanel.createOrShow(this._folderReposManager.context.extensionUri, this._folderReposManager, this.pullRequestModel); + PullRequestOverviewPanel.scrollToReview(); + + /* __GDPR__ + "pr.openDescription" : {} + */ + this._folderReposManager.telemetry.sendTelemetryEvent('pr.openDescription'); + } + + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { + const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); + const comment = new TemporaryComment(thread, input, inDraft, currentUser); + this.updateCommentThreadComments(thread, [...thread.comments, comment]); + return comment.id; + } + + private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { + thread.comments = newComments; + updateCommentThreadLabel(thread); + } + + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise { + const pendingReviewId = await this.pullRequestModel.getPendingReviewId(); + await this.createOrReplyComment(thread, input, !pendingReviewId); + } + + public async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this.pullRequestModel.resolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); + } + } + + public async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this.pullRequestModel.unresolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); + } + } + + public async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { + if (comment.parent!.uri.scheme !== Schemes.Pr) { + return; + } + + if ( + comment.reactions && + !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) + ) { + // add reaction + await this.pullRequestModel.addCommentReaction(comment.rawComment.graphNodeId, reaction); + } else { + await this.pullRequestModel.deleteCommentReaction(comment.rawComment.graphNodeId, reaction); + } + } + + private setContextKey(inDraftMode: boolean): void { + vscode.commands.executeCommand('setContext', 'prInDraft', inDraftMode); + } + + dispose() { + Object.keys(this._commentThreadCache).forEach(key => { + this._commentThreadCache[key].forEach(thread => thread.dispose()); + }); + + unregisterCommentHandler(this._commentHandlerId); + + this._disposables.forEach(d => d.dispose()); + } +} diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts index 64e09e48bd..6c9700a968 100644 --- a/src/view/pullRequestCommentControllerRegistry.ts +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -1,124 +1,125 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as vscode from 'vscode'; -import { fromPRUri } from '../common/uri'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GHPRComment } from '../github/prComment'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { CommentReactionHandler } from '../github/utils'; -import { PullRequestCommentController } from './pullRequestCommentController'; - -interface PullRequestCommentHandlerInfo { - handler: PullRequestCommentController & CommentReactionHandler; - refCount: number; - dispose: () => void; -} - -export class PRCommentControllerRegistry implements vscode.CommentingRangeProvider, CommentReactionHandler, vscode.Disposable { - private _prCommentHandlers: { [key: number]: PullRequestCommentHandlerInfo } = {}; - private _prCommentingRangeProviders: { [key: number]: vscode.CommentingRangeProvider2 } = {}; - private _activeChangeListeners: Map = new Map(); - - constructor(public commentsController: vscode.CommentController) { - this.commentsController.commentingRangeProvider = this; - this.commentsController.reactionHandler = this.toggleReaction.bind(this); - } - - async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { - const uri = document.uri; - const params = fromPRUri(uri); - - if (!params || !this._prCommentingRangeProviders[params.prNumber]) { - return; - } - - const provideCommentingRanges = this._prCommentingRangeProviders[params.prNumber].provideCommentingRanges.bind( - this._prCommentingRangeProviders[params.prNumber], - ); - - return provideCommentingRanges(document, token); - } - - async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { - const uri = comment.parent!.uri; - const params = fromPRUri(uri); - - if ( - !params || - !this._prCommentHandlers[params.prNumber] || - !this._prCommentHandlers[params.prNumber].handler.toggleReaction - ) { - return; - } - - const toggleReaction = this._prCommentHandlers[params.prNumber].handler.toggleReaction!.bind( - this._prCommentHandlers[params.prNumber].handler, - ); - - return toggleReaction(comment, reaction); - } - - public unregisterCommentController(prNumber: number): void { - if (this._prCommentHandlers[prNumber]) { - this._prCommentHandlers[prNumber].dispose(); - delete this._prCommentHandlers[prNumber]; - } - } - - public registerCommentController(prNumber: number, pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager): vscode.Disposable { - if (this._prCommentHandlers[prNumber]) { - this._prCommentHandlers[prNumber].refCount += 1; - return this._prCommentHandlers[prNumber]; - } - - if (!this._activeChangeListeners.has(folderRepositoryManager)) { - this._activeChangeListeners.set(folderRepositoryManager, folderRepositoryManager.onDidChangeActivePullRequest(e => { - if (e.old) { - this._prCommentHandlers[e.old]?.dispose(); - } - })); - } - - const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController); - this._prCommentHandlers[prNumber] = { - handler, - refCount: 1, - dispose: () => { - if (!this._prCommentHandlers[prNumber]) { - return; - } - - this._prCommentHandlers[prNumber].refCount -= 1; - if (this._prCommentHandlers[prNumber].refCount === 0) { - this._prCommentHandlers[prNumber].handler.dispose(); - delete this._prCommentHandlers[prNumber]; - } - } - }; - - return this._prCommentHandlers[prNumber]; - } - - public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { - this._prCommentingRangeProviders[prNumber] = provider; - - return { - dispose: () => { - delete this._prCommentingRangeProviders[prNumber]; - } - }; - } - - dispose() { - Object.keys(this._prCommentHandlers).forEach(key => { - this._prCommentHandlers[key].handler.dispose(); - }); - - this._prCommentingRangeProviders = {}; - this._prCommentHandlers = {}; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + + +import * as vscode from 'vscode'; +import { fromPRUri } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GHPRComment } from '../github/prComment'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { CommentReactionHandler } from '../github/utils'; +import { PullRequestCommentController } from './pullRequestCommentController'; + +interface PullRequestCommentHandlerInfo { + handler: PullRequestCommentController & CommentReactionHandler; + refCount: number; + dispose: () => void; +} + +export class PRCommentControllerRegistry implements vscode.CommentingRangeProvider, CommentReactionHandler, vscode.Disposable { + private _prCommentHandlers: { [key: number]: PullRequestCommentHandlerInfo } = {}; + private _prCommentingRangeProviders: { [key: number]: vscode.CommentingRangeProvider2 } = {}; + private _activeChangeListeners: Map = new Map(); + + constructor(public commentsController: vscode.CommentController) { + this.commentsController.commentingRangeProvider = this; + this.commentsController.reactionHandler = this.toggleReaction.bind(this); + } + + async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const uri = document.uri; + const params = fromPRUri(uri); + + if (!params || !this._prCommentingRangeProviders[params.prNumber]) { + return; + } + + const provideCommentingRanges = this._prCommentingRangeProviders[params.prNumber].provideCommentingRanges.bind( + this._prCommentingRangeProviders[params.prNumber], + ); + + return provideCommentingRanges(document, token); + } + + async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { + const uri = comment.parent!.uri; + const params = fromPRUri(uri); + + if ( + !params || + !this._prCommentHandlers[params.prNumber] || + !this._prCommentHandlers[params.prNumber].handler.toggleReaction + ) { + return; + } + + const toggleReaction = this._prCommentHandlers[params.prNumber].handler.toggleReaction!.bind( + this._prCommentHandlers[params.prNumber].handler, + ); + + return toggleReaction(comment, reaction); + } + + public unregisterCommentController(prNumber: number): void { + if (this._prCommentHandlers[prNumber]) { + this._prCommentHandlers[prNumber].dispose(); + delete this._prCommentHandlers[prNumber]; + } + } + + public registerCommentController(prNumber: number, pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager): vscode.Disposable { + if (this._prCommentHandlers[prNumber]) { + this._prCommentHandlers[prNumber].refCount += 1; + return this._prCommentHandlers[prNumber]; + } + + if (!this._activeChangeListeners.has(folderRepositoryManager)) { + this._activeChangeListeners.set(folderRepositoryManager, folderRepositoryManager.onDidChangeActivePullRequest(e => { + if (e.old) { + this._prCommentHandlers[e.old]?.dispose(); + } + })); + } + + const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController); + this._prCommentHandlers[prNumber] = { + handler, + refCount: 1, + dispose: () => { + if (!this._prCommentHandlers[prNumber]) { + return; + } + + this._prCommentHandlers[prNumber].refCount -= 1; + if (this._prCommentHandlers[prNumber].refCount === 0) { + this._prCommentHandlers[prNumber].handler.dispose(); + delete this._prCommentHandlers[prNumber]; + } + } + }; + + return this._prCommentHandlers[prNumber]; + } + + public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { + this._prCommentingRangeProviders[prNumber] = provider; + + return { + dispose: () => { + delete this._prCommentingRangeProviders[prNumber]; + } + }; + } + + dispose() { + Object.keys(this._prCommentHandlers).forEach(key => { + this._prCommentHandlers[key].handler.dispose(); + }); + + this._prCommentingRangeProviders = {}; + this._prCommentHandlers = {}; + } +} diff --git a/src/view/quickpick.ts b/src/view/quickpick.ts index 637f0273be..0f9528b279 100644 --- a/src/view/quickpick.ts +++ b/src/view/quickpick.ts @@ -1,24 +1,25 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Remote } from '../common/remote'; - -export class RemoteQuickPickItem implements vscode.QuickPickItem { - detail?: string; - picked?: boolean; - - static fromRemote(remote: Remote) { - return new this(remote.owner, remote.repositoryName, remote.url, remote); - } - - constructor( - public owner: string, - public name: string, - public description: string, - public remote?: Remote, - public label = `${owner}:${name}`, - ) {} -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Remote } from '../common/remote'; + +export class RemoteQuickPickItem implements vscode.QuickPickItem { + detail?: string; + picked?: boolean; + + static fromRemote(remote: Remote) { + return new this(remote.owner, remote.repositoryName, remote.url, remote); + } + + constructor( + public owner: string, + public name: string, + public description: string, + public remote?: Remote, + public label = `${owner}:${name}`, + ) {} +} diff --git a/src/view/readonlyFileSystemProvider.ts b/src/view/readonlyFileSystemProvider.ts index 1bb271b349..8e9abab4c9 100644 --- a/src/view/readonlyFileSystemProvider.ts +++ b/src/view/readonlyFileSystemProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; export abstract class ReadonlyFileSystemProvider implements vscode.FileSystemProvider { @@ -47,4 +48,4 @@ export abstract class ReadonlyFileSystemProvider implements vscode.FileSystemPro rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean; }): void { /** no op */ } -} \ No newline at end of file +} diff --git a/src/view/repositoryFileSystemProvider.ts b/src/view/repositoryFileSystemProvider.ts index 01c15d9d1c..e87e1cefe6 100644 --- a/src/view/repositoryFileSystemProvider.ts +++ b/src/view/repositoryFileSystemProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { GitApiImpl } from '../api/api1'; import Logger from '../common/logger'; @@ -47,4 +48,4 @@ export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemPro } return new Promise(resolve => this.credentialStore.onDidGetSession(() => resolve())); } -} \ No newline at end of file +} diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index 235991eb22..058cc01b07 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -1,916 +1,917 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as nodePath from 'path'; -import { v4 as uuid } from 'uuid'; -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IReviewThread, SubjectType } from '../common/comment'; -import { getCommentingRanges } from '../common/commentingRanges'; -import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; -import { GitChangeType } from '../common/file'; -import Logger from '../common/logger'; -import { PR_SETTINGS_NAMESPACE, PULL_BRANCH, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; -import { fromReviewUri, ReviewUriParams, Schemes, toReviewUri } from '../common/uri'; -import { dispose, formatError, groupBy, uniqBy } from '../common/utils'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; -import { - CommentReactionHandler, - createVSCodeCommentThreadForReviewThread, - isFileInRepo, - threadRange, - updateCommentReviewState, - updateCommentThreadLabel, - updateThread, - updateThreadWithRange, -} from '../github/utils'; -import { RemoteFileChangeModel } from './fileChangeModel'; -import { ReviewManager } from './reviewManager'; -import { ReviewModel } from './reviewModel'; -import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; - -export class ReviewCommentController - implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider2, CommentReactionHandler { - private static readonly ID = 'ReviewCommentController'; - private _localToDispose: vscode.Disposable[] = []; - private _commentHandlerId: string; - - private _commentController: vscode.CommentController; - - public get commentController(): vscode.CommentController | undefined { - return this._commentController; - } - - // Note: marked as protected so that tests can verify caches have been updated correctly without breaking type safety - protected _workspaceFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; - protected _reviewSchemeFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; - protected _obsoleteFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; - - protected _visibleNormalTextEditors: vscode.TextEditor[] = []; - - private _pendingCommentThreadAdds: GHPRCommentThread[] = []; - private readonly _context: vscode.ExtensionContext; - - constructor( - private _reviewManager: ReviewManager, - private _reposManager: FolderRepositoryManager, - private _repository: Repository, - private _reviewModel: ReviewModel, - ) { - this._context = this._reposManager.context; - this._commentController = vscode.comments.createCommentController( - `github-review-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest!.number}`, - vscode.l10n.t('Pull Request ({0})', _reposManager.activePullRequest!.title), - ); - this._commentController.commentingRangeProvider = this as vscode.CommentingRangeProvider; - this._commentController.reactionHandler = this.toggleReaction.bind(this); - this._localToDispose.push(this._commentController); - this._commentHandlerId = uuid(); - registerCommentHandler(this._commentHandlerId, this); - } - - // #region initialize - async initialize(): Promise { - this._visibleNormalTextEditors = vscode.window.visibleTextEditors.filter( - ed => ed.document.uri.scheme !== 'comment', - ); - await this._reposManager.activePullRequest!.validateDraftMode(); - await this.initializeCommentThreads(); - await this.registerListeners(); - } - - /** - * Creates a comment thread for a thread that is not on the latest changes. - * @param path The path to the file the comment thread is on. - * @param thread The comment thread information from GitHub. - * @returns A GHPRCommentThread that has been created on an editor. - */ - private async createOutdatedCommentThread(path: string, thread: IReviewThread): Promise { - const commit = thread.comments[0].originalCommitId!; - const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, path)); - const reviewUri = toReviewUri( - uri, - path, - undefined, - commit, - true, - { base: thread.diffSide === DiffSide.LEFT }, - this._repository.rootUri, - ); - - const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); - return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); - } - - /** - * Creates a comment thread for a thread that appears on the right-hand side, which is a - * document that has a scheme matching the workspace uri scheme, typically 'file'. - * @param uri The uri to the file the comment thread is on. - * @param path The path to the file the comment thread is on. - * @param thread The comment thread information from GitHub. - * @returns A GHPRCommentThread that has been created on an editor. - */ - private async createWorkspaceCommentThread( - uri: vscode.Uri, - path: string, - thread: IReviewThread, - ): Promise { - let startLine = thread.startLine; - let endLine = thread.endLine; - const localDiff = await this._repository.diffWithHEAD(path); - if (localDiff) { - startLine = mapOldPositionToNew(localDiff, startLine); - endLine = mapOldPositionToNew(localDiff, endLine); - } - - let range: vscode.Range | undefined; - if (thread.subjectType !== SubjectType.FILE) { - const adjustedStartLine = startLine - 1; - const adjustedEndLine = endLine - 1; - if (adjustedStartLine < 0 || adjustedEndLine < 0) { - Logger.error(`Mapped new position for workspace comment thread is invalid. Original: (${thread.startLine}, ${thread.endLine}) New: (${adjustedStartLine}, ${adjustedEndLine})`); - } - range = threadRange(adjustedStartLine, adjustedEndLine); - } - return createVSCodeCommentThreadForReviewThread(this._context, uri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); - } - - /** - * Creates a comment thread for a thread that appears on the left-hand side, which is a - * document that has a 'review' scheme whose content is created by the extension. - * @param uri The uri to the file the comment thread is on. - * @param path The path to the file the comment thread is on. - * @param thread The comment thread information from GitHub. - * @returns A GHPRCommentThread that has been created on an editor. - */ - private async createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): Promise { - if (!this._reposManager.activePullRequest?.mergeBase) { - throw new Error('Cannot create review comment thread without an active pull request base.'); - } - const reviewUri = toReviewUri( - uri, - path, - undefined, - this._reposManager.activePullRequest.mergeBase, - false, - { base: true }, - this._repository.rootUri, - ); - - const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, thread.endLine - 1); - return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); - } - - private async doInitializeCommentThreads(reviewThreads: IReviewThread[]): Promise { - // First clean up all the old comments. - for (const key in this._workspaceFileChangeCommentThreads) { - dispose(this._workspaceFileChangeCommentThreads[key]); - } - this._workspaceFileChangeCommentThreads = {}; - for (const key in this._reviewSchemeFileChangeCommentThreads) { - dispose(this._reviewSchemeFileChangeCommentThreads[key]); - } - this._reviewSchemeFileChangeCommentThreads = {}; - for (const key in this._obsoleteFileChangeCommentThreads) { - dispose(this._obsoleteFileChangeCommentThreads[key]); - } - this._obsoleteFileChangeCommentThreads = {}; - - const threadsByPath = groupBy(reviewThreads, thread => thread.path); - - Object.keys(threadsByPath).forEach(path => { - const threads = threadsByPath[path]; - const firstThread = threads[0]; - if (firstThread) { - const fullPath = nodePath.join(this._repository.rootUri.path, firstThread.path).replace(/\\/g, '/'); - const uri = this._repository.rootUri.with({ path: fullPath }); - - let rightSideCommentThreads: GHPRCommentThread[] = []; - let leftSideThreads: GHPRCommentThread[] = []; - let outdatedCommentThreads: GHPRCommentThread[] = []; - - const threadPromises = threads.map(async thread => { - if (thread.isOutdated) { - outdatedCommentThreads.push(await this.createOutdatedCommentThread(path, thread)); - } else { - if (thread.diffSide === DiffSide.RIGHT) { - rightSideCommentThreads.push(await this.createWorkspaceCommentThread(uri, path, thread)); - } else { - leftSideThreads.push(await this.createReviewCommentThread(uri, path, thread)); - } - } - }); - - Promise.all(threadPromises); - - this._workspaceFileChangeCommentThreads[path] = rightSideCommentThreads; - this._reviewSchemeFileChangeCommentThreads[path] = leftSideThreads; - this._obsoleteFileChangeCommentThreads[path] = outdatedCommentThreads; - } - }); - } - - private async initializeCommentThreads(): Promise { - const activePullRequest = this._reposManager.activePullRequest; - if (!activePullRequest || !activePullRequest.isResolved()) { - return; - } - return this.doInitializeCommentThreads(activePullRequest.reviewThreadsCache); - } - - private async registerListeners(): Promise { - const activePullRequest = this._reposManager.activePullRequest; - if (!activePullRequest) { - return; - } - - this._localToDispose.push( - activePullRequest.onDidChangePendingReviewState(newDraftMode => { - [ - this._workspaceFileChangeCommentThreads, - this._obsoleteFileChangeCommentThreads, - this._reviewSchemeFileChangeCommentThreads, - ].forEach(commentThreadMap => { - for (const fileName in commentThreadMap) { - commentThreadMap[fileName].forEach(thread => { - updateCommentReviewState(thread, newDraftMode); - updateCommentThreadLabel(thread); - }); - } - }); - }), - ); - - this._localToDispose.push( - activePullRequest.onDidChangeReviewThreads(e => { - e.added.forEach(async thread => { - const { path } = thread; - - const index = this._pendingCommentThreadAdds.findIndex(async t => { - const fileName = this.gitRelativeRootPath(t.uri.path); - if (fileName !== thread.path) { - return false; - } - - const diff = await this.getContentDiff(t.uri, fileName); - const line = t.range ? mapNewPositionToOld(diff, t.range.end.line) : 0; - const sameLine = line + 1 === thread.endLine; - return sameLine; - }); - - let newThread: GHPRCommentThread; - if (index > -1) { - newThread = this._pendingCommentThreadAdds[index]; - newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread, activePullRequest.githubRepository)); - updateThreadWithRange(this._context, newThread, thread, activePullRequest.githubRepository); - this._pendingCommentThreadAdds.splice(index, 1); - } else { - const fullPath = nodePath.join(this._repository.rootUri.path, path).replace(/\\/g, '/'); - const uri = this._repository.rootUri.with({ path: fullPath }); - if (thread.isOutdated) { - newThread = await this.createOutdatedCommentThread(path, thread); - } else { - if (thread.diffSide === DiffSide.RIGHT) { - newThread = await this.createWorkspaceCommentThread(uri, path, thread); - } else { - newThread = await this.createReviewCommentThread(uri, path, thread); - } - } - } - - const threadMap = thread.isOutdated - ? this._obsoleteFileChangeCommentThreads - : thread.diffSide === DiffSide.RIGHT - ? this._workspaceFileChangeCommentThreads - : this._reviewSchemeFileChangeCommentThreads; - - if (threadMap[path]) { - threadMap[path].push(newThread); - } else { - threadMap[path] = [newThread]; - } - }); - - e.changed.forEach(thread => { - const threadMap = thread.isOutdated - ? this._obsoleteFileChangeCommentThreads - : thread.diffSide === DiffSide.RIGHT - ? this._workspaceFileChangeCommentThreads - : this._reviewSchemeFileChangeCommentThreads; - - const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; - if (index > -1) { - const matchingThread = threadMap[thread.path][index]; - updateThread(this._context, matchingThread, thread, activePullRequest.githubRepository); - } - }); - - e.removed.forEach(thread => { - const threadMap = thread.isOutdated - ? this._obsoleteFileChangeCommentThreads - : thread.diffSide === DiffSide.RIGHT - ? this._workspaceFileChangeCommentThreads - : this._reviewSchemeFileChangeCommentThreads; - - const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; - if (index > -1) { - const matchingThread = threadMap[thread.path][index]; - threadMap[thread.path].splice(index, 1); - matchingThread.dispose(); - } - }); - }), - ); - } - - public updateCommentExpandState(expand: boolean) { - const activePullRequest = this._reposManager.activePullRequest; - if (!activePullRequest) { - return undefined; - } - - function updateThreads(activePullRequest: PullRequestModel, threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map>) { - if (reviewThreads.size === 0) { - return; - } - for (const path of reviewThreads.keys()) { - const reviewThreadsForPath = reviewThreads.get(path)!; - const commentThreads = threads[path]; - for (const commentThread of commentThreads) { - const reviewThread = reviewThreadsForPath.get(commentThread.gitHubThreadId)!; - updateThread(this._context, commentThread, reviewThread, activePullRequest.githubRepository, expand); - } - } - } - - const obsoleteReviewThreads: Map> = new Map(); - const reviewSchemeReviewThreads: Map> = new Map(); - const workspaceFileReviewThreads: Map> = new Map(); - for (const reviewThread of activePullRequest.reviewThreadsCache) { - let mapToUse: Map>; - if (reviewThread.isOutdated) { - mapToUse = obsoleteReviewThreads; - } else { - if (reviewThread.diffSide === DiffSide.RIGHT) { - mapToUse = workspaceFileReviewThreads; - } else { - mapToUse = reviewSchemeReviewThreads; - } - } - if (!mapToUse.has(reviewThread.path)) { - mapToUse.set(reviewThread.path, new Map()); - } - mapToUse.get(reviewThread.path)!.set(reviewThread.id, reviewThread); - } - updateThreads(activePullRequest, this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); - updateThreads(activePullRequest, this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); - updateThreads(activePullRequest, this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); - } - - private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean { - a = a.filter(ed => ed.document.uri.scheme !== 'comment'); - b = b.filter(ed => ed.document.uri.scheme !== 'comment'); - - a = uniqBy(a, editor => editor.document.uri.toString()); - b = uniqBy(b, editor => editor.document.uri.toString()); - - if (a.length !== b.length) { - return false; - } - - for (let i = 0; i < a.length; i++) { - const findRet = b.find(editor => editor.document.uri.toString() === a[i].document.uri.toString()); - - if (!findRet) { - return false; - } - } - - return true; - } - - // #endregion - - hasCommentThread(thread: vscode.CommentThread2): boolean { - if (thread.uri.scheme === Schemes.Review) { - return true; - } - - - if (!isFileInRepo(this._repository, thread.uri)) { - return false; - } - - if (thread.uri.scheme === this._repository.rootUri.scheme) { - return true; - } - - return false; - } - - async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { - let query: ReviewUriParams | undefined = - (document.uri.query && document.uri.query !== '') ? fromReviewUri(document.uri.query) : undefined; - - if (query) { - const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._reviewModel.localFileChanges, document.uri); - - if (matchedFile) { - Logger.debug('Found matched file for commenting ranges.', ReviewCommentController.ID); - return { ranges: getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID), fileComments: true }; - } - } - - if (!isFileInRepo(this._repository, document.uri)) { - if (document.uri.scheme !== 'output') { - Logger.debug('No commenting ranges: File is not in the current repository.', ReviewCommentController.ID); - } - return; - } - - if (document.uri.scheme === this._repository.rootUri.scheme) { - if (!this._reposManager.activePullRequest!.isResolved()) { - Logger.debug('No commenting ranges: Active PR has not been resolved.', ReviewCommentController.ID); - return; - } - - const fileName = this.gitRelativeRootPath(document.uri.path); - const matchedFile = gitFileChangeNodeFilter(this._reviewModel.localFileChanges).find( - fileChange => fileChange.fileName === fileName, - ); - const ranges: vscode.Range[] = []; - - if (matchedFile) { - const diffHunks = await matchedFile.changeModel.diffHunks(); - if ((matchedFile.status === GitChangeType.RENAME) && (diffHunks.length === 0)) { - Logger.debug('No commenting ranges: File was renamed with no diffs.', ReviewCommentController.ID); - return; - } - - const contentDiff = await this.getContentDiff(document.uri, matchedFile.fileName); - - for (let i = 0; i < diffHunks.length; i++) { - const diffHunk = diffHunks[i]; - const start = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber); - const end = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber + diffHunk.newLength - 1); - if (start > 0 && end > 0) { - ranges.push(new vscode.Range(start - 1, 0, end - 1, 0)); - } - } - - if (ranges.length === 0) { - Logger.debug('No commenting ranges: File has diffs, but they could not be mapped to current lines.', ReviewCommentController.ID); - } - } else { - Logger.debug('No commenting ranges: File does not match any of the files in the review.', ReviewCommentController.ID); - } - - Logger.debug(`Providing ${ranges.length} commenting ranges for ${nodePath.basename(document.uri.fsPath)}.`, ReviewCommentController.ID); - return { ranges, fileComments: ranges.length > 0 }; - } else { - Logger.debug('No commenting ranges: File scheme differs from repository scheme.', ReviewCommentController.ID); - } - - return; - } - - // #endregion - - private async getContentDiff(uri: vscode.Uri, fileName: string, retry: boolean = true): Promise { - const matchedEditor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.toString() === uri.toString(), - ); - if (!this._reposManager.activePullRequest?.head) { - Logger.error('Failed to get content diff. Cannot get content diff without an active pull request head.'); - throw new Error('Cannot get content diff without an active pull request head.'); - } - - try { - if (matchedEditor && matchedEditor.document.isDirty) { - const documentText = matchedEditor.document.getText(); - const details = await this._repository.getObjectDetails( - this._reposManager.activePullRequest.head.sha, - fileName, - ); - const idAtLastCommit = details.object; - const idOfCurrentText = await this._repository.hashObject(documentText); - - // git diff - return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); - } else { - return await this._repository.diffWith(this._reposManager.activePullRequest.head.sha, fileName); - } - } catch (e) { - Logger.error(`Failed to get content diff. ${formatError(e)}`); - if ((e.stderr as string | undefined)?.includes('bad object')) { - if (this._repository.state.HEAD?.upstream && retry) { - const pullSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true) && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt') === 'always'); - if (pullSetting) { - try { - await this._repository.pull(); - return this.getContentDiff(uri, fileName, false); - } catch (e) { - // No remote branch - } - } else if (this._repository.state.HEAD?.commit) { - return this._repository.diffWith(this._repository.state.HEAD.commit, fileName); - } - } - if (this._reposManager.activePullRequest.isOpen) { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to get comment locations for commit {0}. This commit is not available locally and there is no remote branch.', this._reposManager.activePullRequest.head.sha)); - } - Logger.warn(`Unable to get comment locations for commit ${this._reposManager.activePullRequest.head.sha}. This commit is not available locally and there is no remote branch.`, ReviewCommentController.ID); - } - throw e; - } - } - - private findMatchedFileChangeForReviewDiffView( - fileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], - uri: vscode.Uri, - ): GitFileChangeNode | undefined { - const query = fromReviewUri(uri.query); - const matchedFiles = fileChanges.filter(fileChangeNode => { - const fileChange = fileChangeNode.changeModel; - if (fileChange instanceof RemoteFileChangeModel) { - return false; - } - - if (fileChange.fileName !== query.path) { - return false; - } - - if (fileChange.filePath.scheme !== 'review') { - // local file - - if (fileChange.sha === query.commit) { - return true; - } - } - - const q = fileChange.filePath.query ? JSON.parse(fileChange.filePath.query) : undefined; - - if (q && (q.commit === query.commit)) { - return true; - } - - const parentQ = fileChange.parentFilePath.query ? JSON.parse(fileChange.parentFilePath.query) : undefined; - - if (parentQ && (parentQ.commit === query.commit)) { - return true; - } - - return false; - }); - - if (matchedFiles && matchedFiles.length) { - return matchedFiles[0] as GitFileChangeNode; - } - return undefined; - } - - private gitRelativeRootPath(path: string) { - // get path relative to git root directory. Handles windows path by converting it to unix path. - return nodePath.relative(this._repository.rootUri.path, path).replace(/\\/g, '/'); - } - - // #endregion - - // #region Review - private getCommentSide(thread: GHPRCommentThread): DiffSide { - if (thread.uri.scheme === Schemes.Review) { - const query = fromReviewUri(thread.uri.query); - return query.base ? DiffSide.LEFT : DiffSide.RIGHT; - } - - return DiffSide.RIGHT; - } - - public async startReview(thread: GHPRCommentThread, input: string): Promise { - const hasExistingComments = thread.comments.length; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); - - try { - if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); - const side = this.getCommentSide(thread); - this._pendingCommentThreadAdds.push(thread); - - // If the thread is on the workspace file, make sure the position - // is properly adjusted to account for any local changes. - let startLine: number | undefined = undefined; - let endLine: number | undefined = undefined; - if (thread.range) { - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; - } - startLine++; - endLine++; - } - - await this._reposManager.activePullRequest!.createReviewThread(input, fileName, startLine, endLine, side); - } else { - const comment = thread.comments[0]; - if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest!.createCommentReply( - input, - comment.rawComment.graphNodeId, - false, - ); - } else { - throw new Error('Cannot reply to temporary comment'); - } - } - } catch (e) { - vscode.window.showErrorMessage(`Starting review failed: ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - public async openReview(): Promise { - await this._reviewManager.openDescription(); - PullRequestOverviewPanel.scrollToReview(); - } - - // #endregion - private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { - const currentUser = await this._reposManager.getCurrentUser(); - const comment = new TemporaryComment(thread, input, inDraft, currentUser); - this.updateCommentThreadComments(thread, [...thread.comments, comment]); - return comment.id; - } - - private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { - thread.comments = newComments; - updateCommentThreadLabel(thread); - } - - private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { - const currentUser = await this._reposManager.getCurrentUser(); - const temporaryComment = new TemporaryComment( - thread, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - !!comment.label, - currentUser, - comment, - ); - thread.comments = thread.comments.map(c => { - if (c instanceof GHPRComment && c.commentId === comment.commentId) { - return temporaryComment; - } - - return c; - }); - - return temporaryComment.id; - } - - // #region Comment - async createOrReplyComment( - thread: GHPRCommentThread, - input: string, - isSingleComment: boolean, - inDraft?: boolean, - ): Promise { - if (!this._reposManager.activePullRequest) { - throw new Error('Cannot create comment without an active pull request.'); - } - - const hasExistingComments = thread.comments.length; - const isDraft = isSingleComment - ? false - : inDraft !== undefined - ? inDraft - : this._reposManager.activePullRequest.hasPendingReview; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); - - try { - if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); - this._pendingCommentThreadAdds.push(thread); - const side = this.getCommentSide(thread); - - // If the thread is on the workspace file, make sure the position - // is properly adjusted to account for any local changes. - let startLine: number | undefined = undefined; - let endLine: number | undefined = undefined; - if (thread.range) { - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; - } - startLine++; - endLine++; - } - await this._reposManager.activePullRequest.createReviewThread( - input, - fileName, - startLine, - endLine, - side, - isSingleComment, - ); - } else { - const comment = thread.comments[0]; - if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest.createCommentReply( - input, - comment.rawComment.graphNodeId, - isSingleComment, - ); - } else { - throw new Error('Cannot reply to temporary comment'); - } - } - - if (isSingleComment) { - await this._reposManager.activePullRequest.submitReview(); - } - } catch (e) { - if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { - vscode.window.showWarningMessage('The comment that you\'re replying to was deleted. Refresh to update.', 'Refresh').then(result => { - if (result === 'Refresh') { - this._reviewManager.updateComments(); - } - }); - } else { - vscode.window.showErrorMessage(`Creating comment failed: ${e}`); - } - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise { - if (!this._reposManager.activePullRequest) { - throw new Error('Cannot create comment on resolve without an active pull request.'); - } - const pendingReviewId = await this._reposManager.activePullRequest.getPendingReviewId(); - await this.createOrReplyComment(thread, input, !pendingReviewId); - } - - async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { - try { - if (input) { - await this.createCommentOnResolve(thread, input); - } - - await this._reposManager.activePullRequest!.resolveReviewThread(thread.gitHubThreadId); - } catch (e) { - vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); - } - } - - async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { - try { - if (input) { - await this.createCommentOnResolve(thread, input); - } - - await this._reposManager.activePullRequest!.unresolveReviewThread(thread.gitHubThreadId); - } catch (e) { - vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); - } - } - - async editComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { - if (comment instanceof GHPRComment) { - const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); - try { - if (!this._reposManager.activePullRequest) { - throw new Error('Unable to find active pull request'); - } - - await this._reposManager.activePullRequest.editReviewComment( - comment.rawComment, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - ); - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(this._context, comment.rawComment, thread); - } - - return c; - }); - } - } - } - - async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { - try { - if (!this._reposManager.activePullRequest) { - throw new Error('Unable to find active pull request'); - } - - if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest.deleteReviewComment(comment.commentId); - } else { - thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); - } - - if (thread.comments.length === 0) { - thread.dispose(); - } else { - updateCommentThreadLabel(thread); - } - - const inDraftMode = await this._reposManager.activePullRequest.validateDraftMode(); - if (inDraftMode !== this._reposManager.activePullRequest.hasPendingReview) { - this._reposManager.activePullRequest.hasPendingReview = inDraftMode; - } - - this.update(); - } catch (e) { - throw new Error(formatError(e)); - } - } - - // #endregion - - // #region Incremental update comments - public async update(): Promise { - await this._reposManager.activePullRequest!.validateDraftMode(); - } - // #endregion - - // #region Reactions - async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { - try { - if (!this._reposManager.activePullRequest) { - throw new Error('Unable to find active pull request'); - } - - if ( - comment.reactions && - !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) - ) { - await this._reposManager.activePullRequest.addCommentReaction( - comment.rawComment.graphNodeId, - reaction, - ); - } else { - await this._reposManager.activePullRequest.deleteCommentReaction( - comment.rawComment.graphNodeId, - reaction, - ); - } - } catch (e) { - throw new Error(formatError(e)); - } - } - - // #endregion - - async applySuggestion(comment: GHPRComment) { - const range = comment.parent.range; - const suggestion = comment.suggestion; - if ((suggestion === undefined) || !range) { - throw new Error('Comment doesn\'t contain a suggestion'); - } - - const editor = vscode.window.visibleTextEditors.find(editor => comment.parent.uri.toString() === editor.document.uri.toString()); - if (!editor) { - throw new Error('Cannot find the editor to apply the suggestion to.'); - } - await editor.edit(builder => { - builder.replace(range.with(undefined, new vscode.Position(range.end.line + 1, 0)), suggestion); - }); - } - - public dispose() { - unregisterCommentHandler(this._commentHandlerId); - this._localToDispose.forEach(d => d.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as nodePath from 'path'; +import { v4 as uuid } from 'uuid'; +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; +import { DiffSide, IReviewThread, SubjectType } from '../common/comment'; +import { getCommentingRanges } from '../common/commentingRanges'; +import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; +import { GitChangeType } from '../common/file'; +import Logger from '../common/logger'; +import { PR_SETTINGS_NAMESPACE, PULL_BRANCH, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; +import { fromReviewUri, ReviewUriParams, Schemes, toReviewUri } from '../common/uri'; +import { dispose, formatError, groupBy, uniqBy } from '../common/utils'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; +import { + CommentReactionHandler, + createVSCodeCommentThreadForReviewThread, + isFileInRepo, + threadRange, + updateCommentReviewState, + updateCommentThreadLabel, + updateThread, + updateThreadWithRange, +} from '../github/utils'; +import { RemoteFileChangeModel } from './fileChangeModel'; +import { ReviewManager } from './reviewManager'; +import { ReviewModel } from './reviewModel'; +import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; + +export class ReviewCommentController + implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider2, CommentReactionHandler { + private static readonly ID = 'ReviewCommentController'; + private _localToDispose: vscode.Disposable[] = []; + private _commentHandlerId: string; + + private _commentController: vscode.CommentController; + + public get commentController(): vscode.CommentController | undefined { + return this._commentController; + } + + // Note: marked as protected so that tests can verify caches have been updated correctly without breaking type safety + protected _workspaceFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; + protected _reviewSchemeFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; + protected _obsoleteFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; + + protected _visibleNormalTextEditors: vscode.TextEditor[] = []; + + private _pendingCommentThreadAdds: GHPRCommentThread[] = []; + private readonly _context: vscode.ExtensionContext; + + constructor( + private _reviewManager: ReviewManager, + private _reposManager: FolderRepositoryManager, + private _repository: Repository, + private _reviewModel: ReviewModel, + ) { + this._context = this._reposManager.context; + this._commentController = vscode.comments.createCommentController( + `github-review-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest!.number}`, + vscode.l10n.t('Pull Request ({0})', _reposManager.activePullRequest!.title), + ); + this._commentController.commentingRangeProvider = this as vscode.CommentingRangeProvider; + this._commentController.reactionHandler = this.toggleReaction.bind(this); + this._localToDispose.push(this._commentController); + this._commentHandlerId = uuid(); + registerCommentHandler(this._commentHandlerId, this); + } + + // #region initialize + async initialize(): Promise { + this._visibleNormalTextEditors = vscode.window.visibleTextEditors.filter( + ed => ed.document.uri.scheme !== 'comment', + ); + await this._reposManager.activePullRequest!.validateDraftMode(); + await this.initializeCommentThreads(); + await this.registerListeners(); + } + + /** + * Creates a comment thread for a thread that is not on the latest changes. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createOutdatedCommentThread(path: string, thread: IReviewThread): Promise { + const commit = thread.comments[0].originalCommitId!; + const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, path)); + const reviewUri = toReviewUri( + uri, + path, + undefined, + commit, + true, + { base: thread.diffSide === DiffSide.LEFT }, + this._repository.rootUri, + ); + + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + } + + /** + * Creates a comment thread for a thread that appears on the right-hand side, which is a + * document that has a scheme matching the workspace uri scheme, typically 'file'. + * @param uri The uri to the file the comment thread is on. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createWorkspaceCommentThread( + uri: vscode.Uri, + path: string, + thread: IReviewThread, + ): Promise { + let startLine = thread.startLine; + let endLine = thread.endLine; + const localDiff = await this._repository.diffWithHEAD(path); + if (localDiff) { + startLine = mapOldPositionToNew(localDiff, startLine); + endLine = mapOldPositionToNew(localDiff, endLine); + } + + let range: vscode.Range | undefined; + if (thread.subjectType !== SubjectType.FILE) { + const adjustedStartLine = startLine - 1; + const adjustedEndLine = endLine - 1; + if (adjustedStartLine < 0 || adjustedEndLine < 0) { + Logger.error(`Mapped new position for workspace comment thread is invalid. Original: (${thread.startLine}, ${thread.endLine}) New: (${adjustedStartLine}, ${adjustedEndLine})`); + } + range = threadRange(adjustedStartLine, adjustedEndLine); + } + return createVSCodeCommentThreadForReviewThread(this._context, uri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + } + + /** + * Creates a comment thread for a thread that appears on the left-hand side, which is a + * document that has a 'review' scheme whose content is created by the extension. + * @param uri The uri to the file the comment thread is on. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): Promise { + if (!this._reposManager.activePullRequest?.mergeBase) { + throw new Error('Cannot create review comment thread without an active pull request base.'); + } + const reviewUri = toReviewUri( + uri, + path, + undefined, + this._reposManager.activePullRequest.mergeBase, + false, + { base: true }, + this._repository.rootUri, + ); + + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, thread.endLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + } + + private async doInitializeCommentThreads(reviewThreads: IReviewThread[]): Promise { + // First clean up all the old comments. + for (const key in this._workspaceFileChangeCommentThreads) { + dispose(this._workspaceFileChangeCommentThreads[key]); + } + this._workspaceFileChangeCommentThreads = {}; + for (const key in this._reviewSchemeFileChangeCommentThreads) { + dispose(this._reviewSchemeFileChangeCommentThreads[key]); + } + this._reviewSchemeFileChangeCommentThreads = {}; + for (const key in this._obsoleteFileChangeCommentThreads) { + dispose(this._obsoleteFileChangeCommentThreads[key]); + } + this._obsoleteFileChangeCommentThreads = {}; + + const threadsByPath = groupBy(reviewThreads, thread => thread.path); + + Object.keys(threadsByPath).forEach(path => { + const threads = threadsByPath[path]; + const firstThread = threads[0]; + if (firstThread) { + const fullPath = nodePath.join(this._repository.rootUri.path, firstThread.path).replace(/\\/g, '/'); + const uri = this._repository.rootUri.with({ path: fullPath }); + + let rightSideCommentThreads: GHPRCommentThread[] = []; + let leftSideThreads: GHPRCommentThread[] = []; + let outdatedCommentThreads: GHPRCommentThread[] = []; + + const threadPromises = threads.map(async thread => { + if (thread.isOutdated) { + outdatedCommentThreads.push(await this.createOutdatedCommentThread(path, thread)); + } else { + if (thread.diffSide === DiffSide.RIGHT) { + rightSideCommentThreads.push(await this.createWorkspaceCommentThread(uri, path, thread)); + } else { + leftSideThreads.push(await this.createReviewCommentThread(uri, path, thread)); + } + } + }); + + Promise.all(threadPromises); + + this._workspaceFileChangeCommentThreads[path] = rightSideCommentThreads; + this._reviewSchemeFileChangeCommentThreads[path] = leftSideThreads; + this._obsoleteFileChangeCommentThreads[path] = outdatedCommentThreads; + } + }); + } + + private async initializeCommentThreads(): Promise { + const activePullRequest = this._reposManager.activePullRequest; + if (!activePullRequest || !activePullRequest.isResolved()) { + return; + } + return this.doInitializeCommentThreads(activePullRequest.reviewThreadsCache); + } + + private async registerListeners(): Promise { + const activePullRequest = this._reposManager.activePullRequest; + if (!activePullRequest) { + return; + } + + this._localToDispose.push( + activePullRequest.onDidChangePendingReviewState(newDraftMode => { + [ + this._workspaceFileChangeCommentThreads, + this._obsoleteFileChangeCommentThreads, + this._reviewSchemeFileChangeCommentThreads, + ].forEach(commentThreadMap => { + for (const fileName in commentThreadMap) { + commentThreadMap[fileName].forEach(thread => { + updateCommentReviewState(thread, newDraftMode); + updateCommentThreadLabel(thread); + }); + } + }); + }), + ); + + this._localToDispose.push( + activePullRequest.onDidChangeReviewThreads(e => { + e.added.forEach(async thread => { + const { path } = thread; + + const index = this._pendingCommentThreadAdds.findIndex(async t => { + const fileName = this.gitRelativeRootPath(t.uri.path); + if (fileName !== thread.path) { + return false; + } + + const diff = await this.getContentDiff(t.uri, fileName); + const line = t.range ? mapNewPositionToOld(diff, t.range.end.line) : 0; + const sameLine = line + 1 === thread.endLine; + return sameLine; + }); + + let newThread: GHPRCommentThread; + if (index > -1) { + newThread = this._pendingCommentThreadAdds[index]; + newThread.gitHubThreadId = thread.id; + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread, activePullRequest.githubRepository)); + updateThreadWithRange(this._context, newThread, thread, activePullRequest.githubRepository); + this._pendingCommentThreadAdds.splice(index, 1); + } else { + const fullPath = nodePath.join(this._repository.rootUri.path, path).replace(/\\/g, '/'); + const uri = this._repository.rootUri.with({ path: fullPath }); + if (thread.isOutdated) { + newThread = await this.createOutdatedCommentThread(path, thread); + } else { + if (thread.diffSide === DiffSide.RIGHT) { + newThread = await this.createWorkspaceCommentThread(uri, path, thread); + } else { + newThread = await this.createReviewCommentThread(uri, path, thread); + } + } + } + + const threadMap = thread.isOutdated + ? this._obsoleteFileChangeCommentThreads + : thread.diffSide === DiffSide.RIGHT + ? this._workspaceFileChangeCommentThreads + : this._reviewSchemeFileChangeCommentThreads; + + if (threadMap[path]) { + threadMap[path].push(newThread); + } else { + threadMap[path] = [newThread]; + } + }); + + e.changed.forEach(thread => { + const threadMap = thread.isOutdated + ? this._obsoleteFileChangeCommentThreads + : thread.diffSide === DiffSide.RIGHT + ? this._workspaceFileChangeCommentThreads + : this._reviewSchemeFileChangeCommentThreads; + + const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; + if (index > -1) { + const matchingThread = threadMap[thread.path][index]; + updateThread(this._context, matchingThread, thread, activePullRequest.githubRepository); + } + }); + + e.removed.forEach(thread => { + const threadMap = thread.isOutdated + ? this._obsoleteFileChangeCommentThreads + : thread.diffSide === DiffSide.RIGHT + ? this._workspaceFileChangeCommentThreads + : this._reviewSchemeFileChangeCommentThreads; + + const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; + if (index > -1) { + const matchingThread = threadMap[thread.path][index]; + threadMap[thread.path].splice(index, 1); + matchingThread.dispose(); + } + }); + }), + ); + } + + public updateCommentExpandState(expand: boolean) { + const activePullRequest = this._reposManager.activePullRequest; + if (!activePullRequest) { + return undefined; + } + + function updateThreads(activePullRequest: PullRequestModel, threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map>) { + if (reviewThreads.size === 0) { + return; + } + for (const path of reviewThreads.keys()) { + const reviewThreadsForPath = reviewThreads.get(path)!; + const commentThreads = threads[path]; + for (const commentThread of commentThreads) { + const reviewThread = reviewThreadsForPath.get(commentThread.gitHubThreadId)!; + updateThread(this._context, commentThread, reviewThread, activePullRequest.githubRepository, expand); + } + } + } + + const obsoleteReviewThreads: Map> = new Map(); + const reviewSchemeReviewThreads: Map> = new Map(); + const workspaceFileReviewThreads: Map> = new Map(); + for (const reviewThread of activePullRequest.reviewThreadsCache) { + let mapToUse: Map>; + if (reviewThread.isOutdated) { + mapToUse = obsoleteReviewThreads; + } else { + if (reviewThread.diffSide === DiffSide.RIGHT) { + mapToUse = workspaceFileReviewThreads; + } else { + mapToUse = reviewSchemeReviewThreads; + } + } + if (!mapToUse.has(reviewThread.path)) { + mapToUse.set(reviewThread.path, new Map()); + } + mapToUse.get(reviewThread.path)!.set(reviewThread.id, reviewThread); + } + updateThreads(activePullRequest, this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); + updateThreads(activePullRequest, this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); + updateThreads(activePullRequest, this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); + } + + private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean { + a = a.filter(ed => ed.document.uri.scheme !== 'comment'); + b = b.filter(ed => ed.document.uri.scheme !== 'comment'); + + a = uniqBy(a, editor => editor.document.uri.toString()); + b = uniqBy(b, editor => editor.document.uri.toString()); + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + const findRet = b.find(editor => editor.document.uri.toString() === a[i].document.uri.toString()); + + if (!findRet) { + return false; + } + } + + return true; + } + + // #endregion + + hasCommentThread(thread: vscode.CommentThread2): boolean { + if (thread.uri.scheme === Schemes.Review) { + return true; + } + + + if (!isFileInRepo(this._repository, thread.uri)) { + return false; + } + + if (thread.uri.scheme === this._repository.rootUri.scheme) { + return true; + } + + return false; + } + + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { + let query: ReviewUriParams | undefined = + (document.uri.query && document.uri.query !== '') ? fromReviewUri(document.uri.query) : undefined; + + if (query) { + const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._reviewModel.localFileChanges, document.uri); + + if (matchedFile) { + Logger.debug('Found matched file for commenting ranges.', ReviewCommentController.ID); + return { ranges: getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID), fileComments: true }; + } + } + + if (!isFileInRepo(this._repository, document.uri)) { + if (document.uri.scheme !== 'output') { + Logger.debug('No commenting ranges: File is not in the current repository.', ReviewCommentController.ID); + } + return; + } + + if (document.uri.scheme === this._repository.rootUri.scheme) { + if (!this._reposManager.activePullRequest!.isResolved()) { + Logger.debug('No commenting ranges: Active PR has not been resolved.', ReviewCommentController.ID); + return; + } + + const fileName = this.gitRelativeRootPath(document.uri.path); + const matchedFile = gitFileChangeNodeFilter(this._reviewModel.localFileChanges).find( + fileChange => fileChange.fileName === fileName, + ); + const ranges: vscode.Range[] = []; + + if (matchedFile) { + const diffHunks = await matchedFile.changeModel.diffHunks(); + if ((matchedFile.status === GitChangeType.RENAME) && (diffHunks.length === 0)) { + Logger.debug('No commenting ranges: File was renamed with no diffs.', ReviewCommentController.ID); + return; + } + + const contentDiff = await this.getContentDiff(document.uri, matchedFile.fileName); + + for (let i = 0; i < diffHunks.length; i++) { + const diffHunk = diffHunks[i]; + const start = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber); + const end = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber + diffHunk.newLength - 1); + if (start > 0 && end > 0) { + ranges.push(new vscode.Range(start - 1, 0, end - 1, 0)); + } + } + + if (ranges.length === 0) { + Logger.debug('No commenting ranges: File has diffs, but they could not be mapped to current lines.', ReviewCommentController.ID); + } + } else { + Logger.debug('No commenting ranges: File does not match any of the files in the review.', ReviewCommentController.ID); + } + + Logger.debug(`Providing ${ranges.length} commenting ranges for ${nodePath.basename(document.uri.fsPath)}.`, ReviewCommentController.ID); + return { ranges, fileComments: ranges.length > 0 }; + } else { + Logger.debug('No commenting ranges: File scheme differs from repository scheme.', ReviewCommentController.ID); + } + + return; + } + + // #endregion + + private async getContentDiff(uri: vscode.Uri, fileName: string, retry: boolean = true): Promise { + const matchedEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === uri.toString(), + ); + if (!this._reposManager.activePullRequest?.head) { + Logger.error('Failed to get content diff. Cannot get content diff without an active pull request head.'); + throw new Error('Cannot get content diff without an active pull request head.'); + } + + try { + if (matchedEditor && matchedEditor.document.isDirty) { + const documentText = matchedEditor.document.getText(); + const details = await this._repository.getObjectDetails( + this._reposManager.activePullRequest.head.sha, + fileName, + ); + const idAtLastCommit = details.object; + const idOfCurrentText = await this._repository.hashObject(documentText); + + // git diff + return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); + } else { + return await this._repository.diffWith(this._reposManager.activePullRequest.head.sha, fileName); + } + } catch (e) { + Logger.error(`Failed to get content diff. ${formatError(e)}`); + if ((e.stderr as string | undefined)?.includes('bad object')) { + if (this._repository.state.HEAD?.upstream && retry) { + const pullSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true) && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt') === 'always'); + if (pullSetting) { + try { + await this._repository.pull(); + return this.getContentDiff(uri, fileName, false); + } catch (e) { + // No remote branch + } + } else if (this._repository.state.HEAD?.commit) { + return this._repository.diffWith(this._repository.state.HEAD.commit, fileName); + } + } + if (this._reposManager.activePullRequest.isOpen) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to get comment locations for commit {0}. This commit is not available locally and there is no remote branch.', this._reposManager.activePullRequest.head.sha)); + } + Logger.warn(`Unable to get comment locations for commit ${this._reposManager.activePullRequest.head.sha}. This commit is not available locally and there is no remote branch.`, ReviewCommentController.ID); + } + throw e; + } + } + + private findMatchedFileChangeForReviewDiffView( + fileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], + uri: vscode.Uri, + ): GitFileChangeNode | undefined { + const query = fromReviewUri(uri.query); + const matchedFiles = fileChanges.filter(fileChangeNode => { + const fileChange = fileChangeNode.changeModel; + if (fileChange instanceof RemoteFileChangeModel) { + return false; + } + + if (fileChange.fileName !== query.path) { + return false; + } + + if (fileChange.filePath.scheme !== 'review') { + // local file + + if (fileChange.sha === query.commit) { + return true; + } + } + + const q = fileChange.filePath.query ? JSON.parse(fileChange.filePath.query) : undefined; + + if (q && (q.commit === query.commit)) { + return true; + } + + const parentQ = fileChange.parentFilePath.query ? JSON.parse(fileChange.parentFilePath.query) : undefined; + + if (parentQ && (parentQ.commit === query.commit)) { + return true; + } + + return false; + }); + + if (matchedFiles && matchedFiles.length) { + return matchedFiles[0] as GitFileChangeNode; + } + return undefined; + } + + private gitRelativeRootPath(path: string) { + // get path relative to git root directory. Handles windows path by converting it to unix path. + return nodePath.relative(this._repository.rootUri.path, path).replace(/\\/g, '/'); + } + + // #endregion + + // #region Review + private getCommentSide(thread: GHPRCommentThread): DiffSide { + if (thread.uri.scheme === Schemes.Review) { + const query = fromReviewUri(thread.uri.query); + return query.base ? DiffSide.LEFT : DiffSide.RIGHT; + } + + return DiffSide.RIGHT; + } + + public async startReview(thread: GHPRCommentThread, input: string): Promise { + const hasExistingComments = thread.comments.length; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); + + try { + if (!hasExistingComments) { + const fileName = this.gitRelativeRootPath(thread.uri.path); + const side = this.getCommentSide(thread); + this._pendingCommentThreadAdds.push(thread); + + // If the thread is on the workspace file, make sure the position + // is properly adjusted to account for any local changes. + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; + } + + await this._reposManager.activePullRequest!.createReviewThread(input, fileName, startLine, endLine, side); + } else { + const comment = thread.comments[0]; + if (comment instanceof GHPRComment) { + await this._reposManager.activePullRequest!.createCommentReply( + input, + comment.rawComment.graphNodeId, + false, + ); + } else { + throw new Error('Cannot reply to temporary comment'); + } + } + } catch (e) { + vscode.window.showErrorMessage(`Starting review failed: ${e}`); + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + public async openReview(): Promise { + await this._reviewManager.openDescription(); + PullRequestOverviewPanel.scrollToReview(); + } + + // #endregion + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { + const currentUser = await this._reposManager.getCurrentUser(); + const comment = new TemporaryComment(thread, input, inDraft, currentUser); + this.updateCommentThreadComments(thread, [...thread.comments, comment]); + return comment.id; + } + + private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { + thread.comments = newComments; + updateCommentThreadLabel(thread); + } + + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { + const currentUser = await this._reposManager.getCurrentUser(); + const temporaryComment = new TemporaryComment( + thread, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + !!comment.label, + currentUser, + comment, + ); + thread.comments = thread.comments.map(c => { + if (c instanceof GHPRComment && c.commentId === comment.commentId) { + return temporaryComment; + } + + return c; + }); + + return temporaryComment.id; + } + + // #region Comment + async createOrReplyComment( + thread: GHPRCommentThread, + input: string, + isSingleComment: boolean, + inDraft?: boolean, + ): Promise { + if (!this._reposManager.activePullRequest) { + throw new Error('Cannot create comment without an active pull request.'); + } + + const hasExistingComments = thread.comments.length; + const isDraft = isSingleComment + ? false + : inDraft !== undefined + ? inDraft + : this._reposManager.activePullRequest.hasPendingReview; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); + + try { + if (!hasExistingComments) { + const fileName = this.gitRelativeRootPath(thread.uri.path); + this._pendingCommentThreadAdds.push(thread); + const side = this.getCommentSide(thread); + + // If the thread is on the workspace file, make sure the position + // is properly adjusted to account for any local changes. + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; + } + await this._reposManager.activePullRequest.createReviewThread( + input, + fileName, + startLine, + endLine, + side, + isSingleComment, + ); + } else { + const comment = thread.comments[0]; + if (comment instanceof GHPRComment) { + await this._reposManager.activePullRequest.createCommentReply( + input, + comment.rawComment.graphNodeId, + isSingleComment, + ); + } else { + throw new Error('Cannot reply to temporary comment'); + } + } + + if (isSingleComment) { + await this._reposManager.activePullRequest.submitReview(); + } + } catch (e) { + if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { + vscode.window.showWarningMessage('The comment that you\'re replying to was deleted. Refresh to update.', 'Refresh').then(result => { + if (result === 'Refresh') { + this._reviewManager.updateComments(); + } + }); + } else { + vscode.window.showErrorMessage(`Creating comment failed: ${e}`); + } + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise { + if (!this._reposManager.activePullRequest) { + throw new Error('Cannot create comment on resolve without an active pull request.'); + } + const pendingReviewId = await this._reposManager.activePullRequest.getPendingReviewId(); + await this.createOrReplyComment(thread, input, !pendingReviewId); + } + + async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this._reposManager.activePullRequest!.resolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); + } + } + + async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this._reposManager.activePullRequest!.unresolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); + } + } + + async editComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { + if (comment instanceof GHPRComment) { + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); + try { + if (!this._reposManager.activePullRequest) { + throw new Error('Unable to find active pull request'); + } + + await this._reposManager.activePullRequest.editReviewComment( + comment.rawComment, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + ); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + return new GHPRComment(this._context, comment.rawComment, thread); + } + + return c; + }); + } + } + } + + async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { + try { + if (!this._reposManager.activePullRequest) { + throw new Error('Unable to find active pull request'); + } + + if (comment instanceof GHPRComment) { + await this._reposManager.activePullRequest.deleteReviewComment(comment.commentId); + } else { + thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); + } + + if (thread.comments.length === 0) { + thread.dispose(); + } else { + updateCommentThreadLabel(thread); + } + + const inDraftMode = await this._reposManager.activePullRequest.validateDraftMode(); + if (inDraftMode !== this._reposManager.activePullRequest.hasPendingReview) { + this._reposManager.activePullRequest.hasPendingReview = inDraftMode; + } + + this.update(); + } catch (e) { + throw new Error(formatError(e)); + } + } + + // #endregion + + // #region Incremental update comments + public async update(): Promise { + await this._reposManager.activePullRequest!.validateDraftMode(); + } + // #endregion + + // #region Reactions + async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise { + try { + if (!this._reposManager.activePullRequest) { + throw new Error('Unable to find active pull request'); + } + + if ( + comment.reactions && + !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) + ) { + await this._reposManager.activePullRequest.addCommentReaction( + comment.rawComment.graphNodeId, + reaction, + ); + } else { + await this._reposManager.activePullRequest.deleteCommentReaction( + comment.rawComment.graphNodeId, + reaction, + ); + } + } catch (e) { + throw new Error(formatError(e)); + } + } + + // #endregion + + async applySuggestion(comment: GHPRComment) { + const range = comment.parent.range; + const suggestion = comment.suggestion; + if ((suggestion === undefined) || !range) { + throw new Error('Comment doesn\'t contain a suggestion'); + } + + const editor = vscode.window.visibleTextEditors.find(editor => comment.parent.uri.toString() === editor.document.uri.toString()); + if (!editor) { + throw new Error('Cannot find the editor to apply the suggestion to.'); + } + await editor.edit(builder => { + builder.replace(range.with(undefined, new vscode.Position(range.end.line + 1, 0)), suggestion); + }); + } + + public dispose() { + unregisterCommentHandler(this._commentHandlerId); + this._localToDispose.forEach(d => d.dispose()); + } +} diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index e4c9b088ab..002cf05b5d 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -1,1347 +1,1354 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as nodePath from 'path'; -import * as vscode from 'vscode'; -import type { Branch, Repository } from '../api/api'; -import { GitApiImpl, GitErrorCodes } from '../api/api1'; -import { openDescription } from '../commands'; -import { DiffChangeType } from '../common/diffHunk'; -import { commands } from '../common/executeCommands'; -import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; -import Logger from '../common/logger'; -import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { - COMMENTS, - FOCUSED_MODE, - IGNORE_PR_BRANCHES, - NEVER_IGNORE_DEFAULT_BRANCH, - OPEN_VIEW, - POST_CREATE, - PR_SETTINGS_NAMESPACE, - QUICK_DIFF, - USE_REVIEW_MODE, -} from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { fromPRUri, fromReviewUri, KnownMediaExtensions, PRUriParams, Schemes, toReviewUri } from '../common/uri'; -import { dispose, formatError, groupBy, isPreRelease, onceEvent } from '../common/utils'; -import { FOCUS_REVIEW_MODE } from '../constants'; -import { GitHubCreatePullRequestLinkProvider } from '../github/createPRLinkProvider'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GitHubRepository, ViewerPermission } from '../github/githubRepository'; -import { GithubItemStateEnum } from '../github/interface'; -import { PullRequestGitHelper, PullRequestMetadata } from '../github/pullRequestGitHelper'; -import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; -import { CreatePullRequestHelper } from './createPullRequestHelper'; -import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; -import { getGitHubFileContent } from './gitHubContentProvider'; -import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; -import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { ProgressHelper } from './progress'; -import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; -import { RemoteQuickPickItem } from './quickpick'; -import { ReviewCommentController } from './reviewCommentController'; -import { ReviewModel } from './reviewModel'; -import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; -import { WebviewViewCoordinator } from './webviewViewCoordinator'; - -export class ReviewManager { - public static ID = 'Review'; - private _localToDispose: vscode.Disposable[] = []; - private _disposables: vscode.Disposable[]; - - private _reviewModel: ReviewModel = new ReviewModel(); - private _lastCommitSha?: string; - private _updateMessageShown: boolean = false; - private _validateStatusInProgress?: Promise; - private _reviewCommentController: ReviewCommentController | undefined; - private _quickDiffProvider: vscode.Disposable | undefined; - private _inMemGitHubContentProvider: vscode.Disposable | undefined; - - private _statusBarItem: vscode.StatusBarItem; - private _prNumber?: number; - private _isShowingLastReviewChanges: boolean = false; - private _previousRepositoryState: { - HEAD: Branch | undefined; - remotes: Remote[]; - }; - - private _switchingToReviewMode: boolean; - private _changesSinceLastReviewProgress: ProgressHelper = new ProgressHelper(); - /** - * Flag set when the "Checkout" action is used and cleared on the next git - * state update, once review mode has been entered. Used to disambiguate - * explicit user action from something like reloading on an existing PR branch. - */ - private justSwitchedToReviewMode: boolean = false; - - public get switchingToReviewMode(): boolean { - return this._switchingToReviewMode; - } - - public set switchingToReviewMode(newState: boolean) { - this._switchingToReviewMode = newState; - if (!newState) { - this.updateState(true); - } - } - - private _isFirstLoad = true; - - constructor( - private _id: number, - private _context: vscode.ExtensionContext, - private readonly _repository: Repository, - private _folderRepoManager: FolderRepositoryManager, - private _telemetry: ITelemetry, - public changesInPrDataProvider: PullRequestChangesTreeDataProvider, - private _pullRequestsTree: PullRequestsTreeDataProvider, - private _showPullRequest: ShowPullRequest, - private readonly _activePrViewCoordinator: WebviewViewCoordinator, - private _createPullRequestHelper: CreatePullRequestHelper, - gitApi: GitApiImpl - ) { - this._switchingToReviewMode = false; - this._disposables = []; - - this._previousRepositoryState = { - HEAD: _repository.state.HEAD, - remotes: parseRepositoryRemotes(this._repository), - }; - - this.registerListeners(); - - if (gitApi.state === 'initialized') { - this.updateState(true); - } - this.pollForStatusChange(); - } - - private registerListeners(): void { - this._disposables.push( - this._repository.state.onDidChange(_ => { - const oldHead = this._previousRepositoryState.HEAD; - const newHead = this._repository.state.HEAD; - - if (!oldHead && !newHead) { - // both oldHead and newHead are undefined - return; - } - - let sameUpstream: boolean | undefined; - - if (!oldHead || !newHead) { - sameUpstream = false; - } else { - sameUpstream = !!oldHead.upstream - ? newHead.upstream && - oldHead.upstream.name === newHead.upstream.name && - oldHead.upstream.remote === newHead.upstream.remote - : !newHead.upstream; - } - - const sameHead = - sameUpstream && // falsy if oldHead or newHead is undefined. - oldHead!.ahead === newHead!.ahead && - oldHead!.behind === newHead!.behind && - oldHead!.commit === newHead!.commit && - oldHead!.name === newHead!.name && - oldHead!.remote === newHead!.remote && - oldHead!.type === newHead!.type; - - const remotes = parseRepositoryRemotes(this._repository); - const sameRemotes = - this._previousRepositoryState.remotes.length === remotes.length && - this._previousRepositoryState.remotes.every(remote => remotes.some(r => remote.equals(r))); - - if (!sameHead || !sameRemotes) { - this._previousRepositoryState = { - HEAD: this._repository.state.HEAD, - remotes: remotes, - }; - - // The first time this event occurs we do want to do visible updates. - // The first time, oldHead will be undefined. - // For subsequent changes, we don't want to make visible updates. - // This occurs on branch changes. - // Note that the visible changes will occur when checking out a PR. - this.updateState(true); - } - - if (oldHead && newHead) { - this.updateBaseBranchMetadata(oldHead, newHead); - } - }), - ); - - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - this.updateFocusedViewMode(); - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${IGNORE_PR_BRANCHES}`)) { - this.validateState(true, false); - } - }), - ); - - this._disposables.push(this._folderRepoManager.onDidChangeActivePullRequest(_ => { - this.updateFocusedViewMode(); - this.registerQuickDiff(); - })); - - GitHubCreatePullRequestLinkProvider.registerProvider(this._disposables, this, this._folderRepoManager); - } - - private async updateBaseBranchMetadata(oldHead: Branch, newHead: Branch) { - if (!oldHead.commit || (oldHead.commit !== newHead.commit) || !newHead.name || !oldHead.name || (oldHead.name === newHead.name)) { - return; - } - - let githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === oldHead.upstream?.remote); - if (githubRepository) { - const metadata = await githubRepository.getMetadata(); - if (metadata.fork && oldHead.name === metadata.default_branch) { - // For forks, we use the upstream repo if it's available. Otherwise, fallback to the fork. - githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.owner === metadata.parent?.owner?.login && repo.remote.repositoryName === metadata.parent?.name) ?? githubRepository; - } - return PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, newHead.name, githubRepository.remote.owner, githubRepository.remote.repositoryName, oldHead.name); - } - } - - private registerQuickDiff() { - if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(QUICK_DIFF)) { - if (this._quickDiffProvider) { - this._quickDiffProvider.dispose(); - this._quickDiffProvider = undefined; - } - const label = this._folderRepoManager.activePullRequest ? vscode.l10n.t('GitHub pull request #{0}', this._folderRepoManager.activePullRequest.number) : vscode.l10n.t('GitHub pull request'); - this._disposables.push(this._quickDiffProvider = vscode.window.registerQuickDiffProvider({ scheme: 'file' }, { - provideOriginalResource: (uri: vscode.Uri) => { - const changeNode = this.reviewModel.localFileChanges.find(changeNode => changeNode.changeModel.filePath.toString() === uri.toString()); - if (changeNode) { - return changeNode.changeModel.parentFilePath; - } - } - }, label, this.repository.rootUri)); - } - } - - - get statusBarItem() { - if (!this._statusBarItem) { - this._statusBarItem = vscode.window.createStatusBarItem('github.pullrequest.status', vscode.StatusBarAlignment.Left); - this._statusBarItem.name = vscode.l10n.t('GitHub Active Pull Request'); - } - - return this._statusBarItem; - } - - get repository(): Repository { - return this._repository; - } - - get reviewModel() { - return this._reviewModel; - } - - private pollForStatusChange() { - setTimeout(async () => { - if (!this._validateStatusInProgress) { - await this.updateComments(); - } - this.pollForStatusChange(); - }, 1000 * 60 * 5); - } - - private get id(): string { - return `${ReviewManager.ID}+${this._id}`; - } - - public async updateState(silent: boolean = false, updateLayout: boolean = true) { - if (this.switchingToReviewMode) { - return; - } - if (!this._validateStatusInProgress) { - Logger.appendLine('Validate state in progress', this.id); - this._validateStatusInProgress = this.validateStatueAndSetContext(silent, updateLayout); - return this._validateStatusInProgress; - } else { - Logger.appendLine('Queuing additional validate state', this.id); - this._validateStatusInProgress = this._validateStatusInProgress.then(async _ => { - return await this.validateStatueAndSetContext(silent, updateLayout); - }); - - return this._validateStatusInProgress; - } - } - - private hasShownLogRequest: boolean = false; - private async validateStatueAndSetContext(silent: boolean, updateLayout: boolean) { - // TODO @alexr00: There's a bug where validateState never returns sometimes. It's not clear what's causing this. - // This is a temporary workaround to ensure that the validateStatueAndSetContext promise always resolves. - // Additional logs have been added, and the issue is being tracked here: https://github.com/microsoft/vscode-pull-request-git/issues/5277 - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise(resolve => { - timeout = setTimeout(() => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - Logger.error('Timeout occurred while validating state.', this.id); - if (!this.hasShownLogRequest && isPreRelease(this._context)) { - this.hasShownLogRequest = true; - vscode.window.showErrorMessage(vscode.l10n.t('A known error has occurred refreshing the repository state. Please share logs from "GitHub Pull Request" in the [tracking issue]({0}).', 'https://github.com/microsoft/vscode-pull-request-github/issues/5277')); - } - } - resolve(); - }, 1000 * 60 * 2); - }); - - const validatePromise = new Promise(resolve => { - this.validateState(silent, updateLayout).then(() => { - vscode.commands.executeCommand('setContext', 'github:stateValidated', true).then(() => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - resolve(); - }); - }); - }); - - return Promise.race([validatePromise, timeoutPromise]); - } - - private async offerIgnoreBranch(currentBranchName): Promise { - const ignoreBranchStateKey = 'githubPullRequest.showOfferIgnoreBranch'; - const showOffer = this._context.workspaceState.get(ignoreBranchStateKey, true); - if (!showOffer) { - return false; - } - // Only show once per day. - const lastOfferTimeKey = 'githubPullRequest.offerIgnoreBranchTime'; - const lastOfferTime = this._context.workspaceState.get(lastOfferTimeKey, 0); - const currentTime = new Date().getTime(); - if ((currentTime - lastOfferTime) < (1000 * 60 * 60 * 24)) { // 1 day - return false; - } - const { base } = await this._folderRepoManager.getPullRequestDefaults(currentBranchName); - if (base !== currentBranchName) { - return false; - } - await this._context.workspaceState.update(lastOfferTimeKey, currentTime); - const ignore = vscode.l10n.t('Ignore Pull Request'); - const dontShow = vscode.l10n.t('Don\'t Show Again'); - const offerResult = await vscode.window.showInformationMessage( - vscode.l10n.t(`There\'s a pull request associated with the default branch '{0}'. Do you want to ignore this Pull Request?`, currentBranchName), - ignore, - dontShow); - if (offerResult === ignore) { - Logger.appendLine(`Branch ${currentBranchName} will now be ignored in ${IGNORE_PR_BRANCHES}.`, this.id); - const settingNamespace = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); - const setting = settingNamespace.get(IGNORE_PR_BRANCHES, []); - setting.push(currentBranchName); - await settingNamespace.update(IGNORE_PR_BRANCHES, setting); - return true; - } else if (offerResult === dontShow) { - await this._context.workspaceState.update(ignoreBranchStateKey, false); - return false; - } - return false; - } - - private async getUpstreamUrlAndName(branch: Branch): Promise<{ url: string | undefined, branchName: string | undefined, remoteName: string | undefined }> { - if (branch.upstream) { - return { remoteName: branch.upstream.remote, branchName: branch.upstream.name, url: undefined }; - } else { - try { - const url = await this.repository.getConfig(`branch.${branch.name}.remote`); - const upstreamBranch = await this.repository.getConfig(`branch.${branch.name}.merge`); - let branchName: string | undefined; - if (upstreamBranch) { - branchName = upstreamBranch.substring('refs/heads/'.length); - } - return { url, branchName, remoteName: undefined }; - } catch (e) { - Logger.appendLine(`Failed to get upstream for branch ${branch.name} from git config.`, this.id); - return { url: undefined, branchName: undefined, remoteName: undefined }; - } - } - } - - private async checkGitHubForPrBranch(branch: Branch): Promise<(PullRequestMetadata & { model: PullRequestModel }) | undefined> { - const { url, branchName, remoteName } = await this.getUpstreamUrlAndName(this._repository.state.HEAD!); - const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(branch, remoteName, url, branchName); - if (metadataFromGithub) { - Logger.appendLine(`Found matching pull request metadata on GitHub for current branch ${branch.name}. Repo: ${metadataFromGithub.owner}/${metadataFromGithub.repositoryName} PR: ${metadataFromGithub.prNumber}`); - await PullRequestGitHelper.associateBranchWithPullRequest( - this._repository, - metadataFromGithub.model, - branch.name!, - ); - return metadataFromGithub; - } - } - - private async resolvePullRequest(metadata: PullRequestMetadata): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { - try { - this._prNumber = metadata.prNumber; - - const { owner, repositoryName } = metadata; - Logger.appendLine('Resolving pull request', this.id); - const pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber); - - if (!pr || !pr.isResolved()) { - await this.clear(true); - this._prNumber = undefined; - Logger.appendLine('This PR is no longer valid', this.id); - return; - } - return pr; - } catch (e) { - Logger.appendLine(`Pull request cannot be resolved: ${e.message}`, this.id); - } - } - - private async validateState(silent: boolean, updateLayout: boolean) { - Logger.appendLine('Validating state...', this.id); - const oldLastCommitSha = this._lastCommitSha; - this._lastCommitSha = undefined; - await this._folderRepoManager.updateRepositories(false); - - if (!this._repository.state.HEAD) { - await this.clear(true); - return; - } - - const branch = this._repository.state.HEAD; - const ignoreBranches = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(IGNORE_PR_BRANCHES); - if (ignoreBranches?.find(value => value === branch.name) && ((branch.remote === 'origin') || !(await this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === branch.remote)?.getMetadata())?.fork)) { - Logger.appendLine(`Branch ${branch.name} is ignored in ${IGNORE_PR_BRANCHES}.`, this.id); - await this.clear(true); - return; - } - - let matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); - - if (!matchingPullRequestMetadata) { - Logger.appendLine(`No matching pull request metadata found locally for current branch ${branch.name}`, this.id); - matchingPullRequestMetadata = await this.checkGitHubForPrBranch(branch); - } - - if (!matchingPullRequestMetadata) { - Logger.appendLine( - `No matching pull request metadata found on GitHub for current branch ${branch.name}`, this.id - ); - await this.clear(true); - return; - } - Logger.appendLine(`Found matching pull request metadata for current branch ${branch.name}. Repo: ${matchingPullRequestMetadata.owner}/${matchingPullRequestMetadata.repositoryName} PR: ${matchingPullRequestMetadata.prNumber}`, this.id); - - const remote = branch.upstream ? branch.upstream.remote : null; - if (!remote) { - Logger.appendLine(`Current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`, this.id); - await this.clear(true); - return; - } - - // we switch to another PR, let's clean up first. - Logger.appendLine( - `current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, this.id - ); - const previousPrNumber = this._prNumber; - let pr = await this.resolvePullRequest(matchingPullRequestMetadata); - if (!pr) { - Logger.appendLine(`Unable to resolve PR #${matchingPullRequestMetadata.prNumber}`, this.id); - return; - } - Logger.appendLine(`Resolved PR #${matchingPullRequestMetadata.prNumber}, state is ${pr.state}`, this.id); - - // Check if the PR is open, if not, check if there's another PR from the same branch on GitHub - if (pr.state !== GithubItemStateEnum.Open) { - const metadataFromGithub = await this.checkGitHubForPrBranch(branch); - if (metadataFromGithub && metadataFromGithub?.prNumber !== pr.number) { - const prFromGitHub = await this.resolvePullRequest(metadataFromGithub); - if (prFromGitHub) { - pr = prFromGitHub; - } - } - } - - const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; - if (previousPrNumber === pr.number && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { - this._validateStatusInProgress = undefined; - return; - } - this._isShowingLastReviewChanges = pr.showChangesSinceReview; - if (previousPrNumber !== pr.number) { - this.clear(false); - } - - const useReviewConfiguration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) - .get<{ merged: boolean, closed: boolean }>(USE_REVIEW_MODE, { merged: true, closed: false }); - - if (pr.isClosed && !useReviewConfiguration.closed) { - Logger.appendLine('This PR is closed', this.id); - await this.clear(true); - return; - } - - if (pr.isMerged && !useReviewConfiguration.merged) { - Logger.appendLine('This PR is merged', this.id); - await this.clear(true); - return; - } - - const neverIgnoreDefaultBranch = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NEVER_IGNORE_DEFAULT_BRANCH, false); - if (!neverIgnoreDefaultBranch) { - // Do not await the result of offering to ignore the branch. - this.offerIgnoreBranch(branch.name); - } - - const previousActive = this._folderRepoManager.activePullRequest; - this._folderRepoManager.activePullRequest = pr; - this._lastCommitSha = pr.head.sha; - - if (this._isFirstLoad) { - this._isFirstLoad = false; - this._folderRepoManager.checkBranchUpToDate(pr, true); - } - - Logger.appendLine('Fetching pull request data', this.id); - if (!silent) { - onceEvent(this._reviewModel.onDidChangeLocalFileChanges)(() => { - if (pr) { - this._upgradePullRequestEditors(pr); - } - }); - } - // Don't await. Events will be fired as part of the initialization. - this.initializePullRequestData(pr); - await this.changesInPrDataProvider.addPrToView( - this._folderRepoManager, - pr, - this._reviewModel, - this.justSwitchedToReviewMode, - this._changesSinceLastReviewProgress - ); - - Logger.appendLine(`Register comments provider`, this.id); - await this.registerCommentController(); - - this._activePrViewCoordinator.setPullRequest(pr, this._folderRepoManager, this, previousActive); - this._localToDispose.push( - pr.onDidChangeChangesSinceReview(async _ => { - this._changesSinceLastReviewProgress.startProgress(); - this.changesInPrDataProvider.refresh(); - await this.updateComments(); - await this.reopenNewReviewDiffs(); - this._changesSinceLastReviewProgress.endProgress(); - }) - ); - Logger.appendLine(`Register in memory content provider`, this.id); - await this.registerGitHubInMemContentProvider(); - - this.statusBarItem.text = '$(git-pull-request) ' + vscode.l10n.t('Pull Request #{0}', pr.number); - this.statusBarItem.command = { - command: 'pr.openDescription', - title: vscode.l10n.t('View Pull Request Description'), - arguments: [pr], - }; - Logger.appendLine(`Display pull request status bar indicator.`, this.id); - this.statusBarItem.show(); - - this.layout(pr, updateLayout, this.justSwitchedToReviewMode ? false : silent); - this.justSwitchedToReviewMode = false; - - this._validateStatusInProgress = undefined; - } - - private layout(pr: PullRequestModel, updateLayout: boolean, silent: boolean) { - const isFocusMode = this._context.workspaceState.get(FOCUS_REVIEW_MODE); - - Logger.appendLine(`Using focus mode = ${isFocusMode}.`, this.id); - Logger.appendLine(`State validation silent = ${silent}.`, this.id); - Logger.appendLine(`PR show should show = ${this._showPullRequest.shouldShow}.`, this.id); - - if ((!silent || this._showPullRequest.shouldShow) && isFocusMode) { - this._doFocusShow(pr, updateLayout); - } else if (!this._showPullRequest.shouldShow && isFocusMode) { - const showPRChangedDisposable = this._showPullRequest.onChangedShowValue(shouldShow => { - Logger.appendLine(`PR show value changed = ${shouldShow}.`, this.id); - if (shouldShow) { - this._doFocusShow(pr, updateLayout); - } - showPRChangedDisposable.dispose(); - }); - this._localToDispose.push(showPRChangedDisposable); - } - } - - private async reopenNewReviewDiffs() { - let hasOpenDiff = false; - await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { - return tabGroup.tabs.map(tab => { - if (tab.input instanceof vscode.TabInputTextDiff) { - if ((tab.input.original.scheme === Schemes.Review)) { - - for (const localChange of this._reviewModel.localFileChanges) { - const fileName = fromReviewUri(tab.input.original.query); - - if (localChange.fileName === fileName.path) { - hasOpenDiff = true; - vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderRepoManager, { preview: tab.isPreview })); - break; - } - } - - } - } - return Promise.resolve(undefined); - }); - }).flat()); - - if (!hasOpenDiff && this._reviewModel.localFileChanges.length) { - this._reviewModel.localFileChanges[0].openDiff(this._folderRepoManager, { preview: true }); - } - } - - private openDiff() { - if (this._reviewModel.localFileChanges.length) { - let fileChangeToShow: GitFileChangeNode[] = []; - for (const fileChange of this._reviewModel.localFileChanges) { - if (fileChange.status === GitChangeType.MODIFY) { - if (KnownMediaExtensions.includes(nodePath.extname(fileChange.fileName))) { - fileChangeToShow.push(fileChange); - } else { - fileChangeToShow.unshift(fileChange); - break; - } - } - } - const change = fileChangeToShow.length ? fileChangeToShow[0] : this._reviewModel.localFileChanges[0]; - change.openDiff(this._folderRepoManager); - } - } - - private _doFocusShow(pr: PullRequestModel, updateLayout: boolean) { - // Respect the setting 'comments.openView' when it's 'never'. - const shouldShowCommentsView = vscode.workspace.getConfiguration(COMMENTS).get<'never' | string>(OPEN_VIEW); - if (shouldShowCommentsView !== 'never') { - commands.executeCommand('workbench.action.focusCommentsPanel'); - } - this._activePrViewCoordinator.show(pr); - if (updateLayout) { - const focusedMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'firstDiff' | 'overview' | 'multiDiff' | false>(FOCUSED_MODE); - if (focusedMode === 'firstDiff') { - if (this._reviewModel.localFileChanges.length) { - this.openDiff(); - } else { - const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { - localFileChangesDisposable.dispose(); - this.openDiff(); - }); - } - } else if (focusedMode === 'overview') { - return this.openDescription(); - } else if (focusedMode === 'multiDiff') { - return PullRequestModel.openChanges(this._folderRepoManager, pr); - } - } - } - - public async _upgradePullRequestEditors(pullRequest: PullRequestModel) { - // Go through all open editors and find pr scheme editors that belong to the active pull request. - // Close the editors, and reopen them from the pull request. - const reopenFilenames: Set<[PRUriParams, PRUriParams]> = new Set(); - await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { - return tabGroup.tabs.map(tab => { - if (tab.input instanceof vscode.TabInputTextDiff) { - if ((tab.input.original.scheme === Schemes.Pr) && (tab.input.modified.scheme === Schemes.Pr)) { - const originalParams = fromPRUri(tab.input.original); - const modifiedParams = fromPRUri(tab.input.modified); - if ((originalParams?.prNumber === pullRequest.number) && (modifiedParams?.prNumber === pullRequest.number)) { - reopenFilenames.add([originalParams, modifiedParams]); - return vscode.window.tabGroups.close(tab); - } - } - } - return Promise.resolve(undefined); - }); - }).flat()); - const reopenPromises: Promise[] = []; - if (reopenFilenames.size) { - for (const localChange of this.reviewModel.localFileChanges) { - for (const prFileChange of reopenFilenames) { - if (Array.isArray(prFileChange)) { - const modifiedPrChange = prFileChange[1]; - if (localChange.fileName === modifiedPrChange.fileName) { - reopenPromises.push(localChange.openDiff(this._folderRepoManager, { preview: false })); - reopenFilenames.delete(prFileChange); - break; - } - } - } - } - } - return Promise.all(reopenPromises); - } - - public async updateComments(): Promise { - const branch = this._repository.state.HEAD; - if (!branch) { - return; - } - - const matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); - if (!matchingPullRequestMetadata) { - return; - } - - const remote = branch.upstream ? branch.upstream.remote : null; - if (!remote) { - return; - } - - if (this._prNumber === undefined || !this._folderRepoManager.activePullRequest) { - return; - } - - const pr = await this._folderRepoManager.resolvePullRequest( - matchingPullRequestMetadata.owner, - matchingPullRequestMetadata.repositoryName, - this._prNumber, - ); - - if (!pr || !pr.isResolved()) { - Logger.warn('This PR is no longer valid', this.id); - return; - } - - await this._folderRepoManager.checkBranchUpToDate(pr, false); - - await this.initializePullRequestData(pr); - await this._reviewCommentController?.update(); - - return Promise.resolve(void 0); - } - - private async getLocalChangeNodes( - pr: PullRequestModel & IResolvedPullRequestModel, - contentChanges: (InMemFileChange | SlimFileChange)[], - ): Promise { - const nodes: GitFileChangeNode[] = []; - const mergeBase = pr.mergeBase || pr.base.sha; - const headSha = pr.head.sha; - - for (let i = 0; i < contentChanges.length; i++) { - const change = contentChanges[i]; - const filePath = nodePath.join(this._repository.rootUri.path, change.fileName).replace(/\\/g, '/'); - const uri = this._repository.rootUri.with({ path: filePath }); - - const modifiedFileUri = - change.status === GitChangeType.DELETE - ? toReviewUri(uri, undefined, undefined, '', false, { base: false }, this._repository.rootUri) - : uri; - - const originalFileUri = toReviewUri( - uri, - change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, - undefined, - change.status === GitChangeType.ADD ? '' : mergeBase, - false, - { base: true }, - this._repository.rootUri, - ); - - const changeModel = new GitFileChangeModel(this._folderRepoManager, pr, change, modifiedFileUri, originalFileUri, headSha, contentChanges.length < 20); - const changedItem = new GitFileChangeNode( - this.changesInPrDataProvider, - this._folderRepoManager, - pr, - changeModel - ); - nodes.push(changedItem); - } - - return nodes; - } - - private async initializePullRequestData(pr: PullRequestModel & IResolvedPullRequestModel): Promise { - try { - const contentChanges = await pr.getFileChangesInfo(); - this._reviewModel.localFileChanges = await this.getLocalChangeNodes(pr, contentChanges); - await Promise.all([pr.initializeReviewComments(), pr.initializeReviewThreadCache(), pr.initializePullRequestFileViewState()]); - this._folderRepoManager.setFileViewedContext(); - const outdatedComments = pr.comments.filter(comment => !comment.position); - - const commitsGroup = groupBy(outdatedComments, comment => comment.originalCommitId!); - const obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; - for (const commit in commitsGroup) { - const commentsForCommit = commitsGroup[commit]; - const commentsForFile = groupBy(commentsForCommit, comment => comment.path!); - - for (const fileName in commentsForFile) { - const oldComments = commentsForFile[fileName]; - const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, fileName)); - const changeModel = new GitFileChangeModel( - this._folderRepoManager, - pr, - { - status: GitChangeType.MODIFY, - fileName, - blobUrl: undefined, - - }, toReviewUri( - uri, - fileName, - undefined, - oldComments[0].originalCommitId!, - true, - { base: false }, - this._repository.rootUri, - ), - toReviewUri( - uri, - fileName, - undefined, - oldComments[0].originalCommitId!, - true, - { base: true }, - this._repository.rootUri, - ), - commit); - const obsoleteFileChange = new GitFileChangeNode( - this.changesInPrDataProvider, - this._folderRepoManager, - pr, - changeModel, - false, - oldComments - ); - - obsoleteFileChanges.push(obsoleteFileChange); - } - } - this._reviewModel.obsoleteFileChanges = obsoleteFileChanges; - - return Promise.resolve(void 0); - } catch (e) { - Logger.error(`Failed to initialize PR data ${e}`, this.id); - } - } - - private async registerGitHubInMemContentProvider() { - try { - this._inMemGitHubContentProvider?.dispose(); - this._inMemGitHubContentProvider = undefined; - - const pr = this._folderRepoManager.activePullRequest; - if (!pr) { - return; - } - const rawChanges = await pr.getFileChangesInfo(); - const mergeBase = pr.mergeBase; - if (!mergeBase) { - return; - } - const changes = rawChanges.map(change => { - if (change instanceof SlimFileChange) { - return new RemoteFileChangeModel(this._folderRepoManager, change, pr); - } - return new InMemFileChangeModel(this._folderRepoManager, - pr as (PullRequestModel & IResolvedPullRequestModel), - change, true, mergeBase); - }); - - this._inMemGitHubContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( - pr.number, - async (uri: vscode.Uri): Promise => { - const params = fromPRUri(uri); - if (!params) { - return ''; - } - const fileChange = changes.find( - contentChange => contentChange.fileName === params.fileName, - ); - - if (!fileChange) { - Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); - return ''; - } - - return provideDocumentContentForChangeModel(this._folderRepoManager, pr, params, fileChange); - - }, - ); - } catch (e) { - Logger.error(`Failed to register in mem content provider: ${e}`, this.id); - } - } - - private async registerCommentController() { - if (this._folderRepoManager.activePullRequest?.reviewThreadsCacheReady && this._reviewModel.hasLocalFileChanges) { - await this.doRegisterCommentController(); - } else { - const changedLocalFilesChangesDisposable: vscode.Disposable | undefined = - this._reviewModel.onDidChangeLocalFileChanges(async () => { - if (this._folderRepoManager.activePullRequest?.reviewThreadsCache && this._reviewModel.hasLocalFileChanges) { - if (changedLocalFilesChangesDisposable) { - changedLocalFilesChangesDisposable.dispose(); - } - await this.doRegisterCommentController(); - } - }); - } - } - - private async doRegisterCommentController() { - if (!this._reviewCommentController) { - this._reviewCommentController = new ReviewCommentController( - this, - this._folderRepoManager, - this._repository, - this._reviewModel, - ); - - await this._reviewCommentController.initialize(); - } - } - - public async switch(pr: PullRequestModel): Promise { - Logger.appendLine(`Switch to Pull Request #${pr.number} - start`, this.id); - this.statusBarItem.text = vscode.l10n.t('{0} Switching to Review Mode', '$(sync~spin)'); - this.statusBarItem.command = undefined; - this.statusBarItem.show(); - this.switchingToReviewMode = true; - - try { - await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { - const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr, progress); - - if (!didLocalCheckout) { - await this._folderRepoManager.fetchAndCheckout(pr, progress); - } - }); - } catch (e) { - Logger.error(`Checkout failed #${JSON.stringify(e)}`, this.id); - this.switchingToReviewMode = false; - - if (e.message === 'User aborted') { - // The user cancelled the action - } else if (e.gitErrorCode && ( - e.gitErrorCode === GitErrorCodes.LocalChangesOverwritten || - e.gitErrorCode === GitErrorCodes.DirtyWorkTree - )) { - // for known git errors, we should provide actions for users to continue. - vscode.window.showErrorMessage(vscode.l10n.t( - 'Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches' - )); - } else if ((e.stderr as string)?.startsWith('fatal: couldn\'t find remote ref') && e.gitCommand === 'fetch') { - // The pull request was checked out, but the upstream branch was deleted - vscode.window.showInformationMessage('The remote branch for this pull request has been deleted. The file contents may not match the remote.'); - } else { - vscode.window.showErrorMessage(formatError(e)); - } - // todo, we should try to recover, for example, git checkout succeeds but set config fails. - if (this._folderRepoManager.activePullRequest) { - this.setStatusForPr(this._folderRepoManager.activePullRequest); - } else { - this.statusBarItem.hide(); - } - return; - } - - try { - this.statusBarItem.text = '$(sync~spin) ' + vscode.l10n.t('Fetching additional data: {0}', `pr/${pr.number}`); - this.statusBarItem.command = undefined; - this.statusBarItem.show(); - - await this._folderRepoManager.fulfillPullRequestMissingInfo(pr); - this._upgradePullRequestEditors(pr); - - /* __GDPR__ - "pr.checkout" : {} - */ - this._telemetry.sendTelemetryEvent('pr.checkout'); - Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, this.id); - } finally { - this.setStatusForPr(pr); - await this._repository.status(); - } - } - - private setStatusForPr(pr: PullRequestModel) { - this.switchingToReviewMode = false; - this.justSwitchedToReviewMode = true; - this.statusBarItem.text = vscode.l10n.t('Pull Request #{0}', pr.number); - this.statusBarItem.command = undefined; - this.statusBarItem.show(); - } - - public async publishBranch(branch: Branch): Promise { - const potentialTargetRemotes = await this._folderRepoManager.getAllGitHubRemotes(); - let selectedRemote = (await this.getRemote( - potentialTargetRemotes, - vscode.l10n.t(`Pick a remote to publish the branch '{0}' to:`, branch.name!), - ))!.remote; - - if (!selectedRemote || branch.name === undefined) { - return; - } - - const githubRepo = await this._folderRepoManager.createGitHubRepository( - selectedRemote, - this._folderRepoManager.credentialStore, - ); - const permission = await githubRepo.getViewerPermission(); - if ( - permission === ViewerPermission.Read || - permission === ViewerPermission.Triage || - permission === ViewerPermission.Unknown - ) { - // No permission to publish the branch to the chosen remote. Offer to fork. - const fork = await this._folderRepoManager.tryOfferToFork(githubRepo); - if (!fork) { - return; - } - selectedRemote = (await this._folderRepoManager.getGitHubRemotes()).find(element => element.remoteName === fork); - } - - if (!selectedRemote) { - return; - } - const remote: Remote = selectedRemote; - - return new Promise(async resolve => { - const inputBox = vscode.window.createInputBox(); - inputBox.value = branch.name!; - inputBox.ignoreFocusOut = true; - inputBox.prompt = - potentialTargetRemotes.length === 1 - ? vscode.l10n.t(`The branch '{0}' is not published yet, pick a name for the upstream branch`, branch.name!) - : vscode.l10n.t('Pick a name for the upstream branch'); - const validate = async function (value: string) { - try { - inputBox.busy = true; - const remoteBranch = await this._reposManager.getBranch(remote, value); - if (remoteBranch) { - inputBox.validationMessage = vscode.l10n.t(`Branch '{0}' already exists in {1}`, value, `${remote.owner}/${remote.repositoryName}`); - } else { - inputBox.validationMessage = undefined; - } - } catch (e) { - inputBox.validationMessage = undefined; - } - - inputBox.busy = false; - }; - await validate(branch.name!); - inputBox.onDidChangeValue(validate.bind(this)); - inputBox.onDidAccept(async () => { - inputBox.validationMessage = undefined; - inputBox.hide(); - try { - // since we are probably pushing a remote branch with a different name, we use the complete syntax - // git push -u origin local_branch:remote_branch - await this._repository.push(remote.remoteName, `${branch.name}:${inputBox.value}`, true); - } catch (err) { - if (err.gitErrorCode === GitErrorCodes.PushRejected) { - vscode.window.showWarningMessage( - vscode.l10n.t(`Can't push refs to remote, try running 'git pull' first to integrate with your change`), - { - modal: true, - }, - ); - - resolve(undefined); - } - - if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { - vscode.window.showWarningMessage( - vscode.l10n.t(`Could not read from remote repository '{0}'. Please make sure you have the correct access rights and the repository exists.`, remote.remoteName), - { - modal: true, - }, - ); - - resolve(undefined); - } - - // we can't handle the error - throw err; - } - - // we don't want to wait for repository status update - const latestBranch = await this._repository.getBranch(branch.name!); - if (!latestBranch || !latestBranch.upstream) { - resolve(undefined); - } - - resolve(latestBranch); - }); - - inputBox.show(); - }); - } - - private async getRemote( - potentialTargetRemotes: Remote[], - placeHolder: string, - defaultUpstream?: RemoteQuickPickItem, - ): Promise { - if (!potentialTargetRemotes.length) { - vscode.window.showWarningMessage(vscode.l10n.t(`No GitHub remotes found. Add a remote and try again.`)); - return; - } - - if (potentialTargetRemotes.length === 1 && !defaultUpstream) { - return RemoteQuickPickItem.fromRemote(potentialTargetRemotes[0]); - } - - if ( - potentialTargetRemotes.length === 1 && - defaultUpstream && - defaultUpstream.owner === potentialTargetRemotes[0].owner && - defaultUpstream.name === potentialTargetRemotes[0].repositoryName - ) { - return defaultUpstream; - } - - let defaultUpstreamWasARemote = false; - const picks: RemoteQuickPickItem[] = potentialTargetRemotes.map(remote => { - const remoteQuickPick = RemoteQuickPickItem.fromRemote(remote); - if (defaultUpstream) { - const { owner, name } = defaultUpstream; - remoteQuickPick.picked = remoteQuickPick.owner === owner && remoteQuickPick.name === name; - if (remoteQuickPick.picked) { - defaultUpstreamWasARemote = true; - } - } - return remoteQuickPick; - }); - if (!defaultUpstreamWasARemote && defaultUpstream) { - picks.unshift(defaultUpstream); - } - - const selected: RemoteQuickPickItem | undefined = await vscode.window.showQuickPick( - picks, - { - ignoreFocusOut: true, - placeHolder: placeHolder, - }, - ); - - if (!selected) { - return; - } - - return selected; - } - - public async createPullRequest(compareBranch?: string): Promise { - const postCreate = async (createdPR: PullRequestModel) => { - const postCreate = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch' | 'checkoutDefaultBranchAndShow' | 'checkoutDefaultBranchAndCopy'>(POST_CREATE, 'openOverview'); - if (postCreate === 'openOverview') { - const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); - await openDescription( - this._context, - this._telemetry, - createdPR, - descriptionNode, - this._folderRepoManager, - true - ); - } else if (postCreate.startsWith('checkoutDefaultBranch')) { - const defaultBranch = await this._folderRepoManager.getPullRequestRepositoryDefaultBranch(createdPR); - if (defaultBranch) { - if (postCreate === 'checkoutDefaultBranch') { - await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); - } if (postCreate === 'checkoutDefaultBranchAndShow') { - await vscode.commands.executeCommand('pr:github.focus'); - await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); - await this._pullRequestsTree.expandPullRequest(createdPR); - } else if (postCreate === 'checkoutDefaultBranchAndCopy') { - await Promise.all([ - this._folderRepoManager.checkoutDefaultBranch(defaultBranch), - vscode.env.clipboard.writeText(createdPR.html_url) - ]); - } - } - } - await this.updateState(false, false); - }; - - return this._createPullRequestHelper.create(this._telemetry, this._context.extensionUri, this._folderRepoManager, compareBranch, postCreate); - } - - public async openDescription(): Promise { - const pullRequest = this._folderRepoManager.activePullRequest; - if (!pullRequest) { - return; - } - - const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); - await openDescription( - this._context, - this._telemetry, - pullRequest, - descriptionNode, - this._folderRepoManager, - true - ); - } - - get isCreatingPullRequest() { - return this._createPullRequestHelper?.isCreatingPullRequest ?? false; - } - - private async updateFocusedViewMode(): Promise { - const focusedSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FOCUSED_MODE); - if (focusedSetting) { - vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); - await this._context.workspaceState.update(FOCUS_REVIEW_MODE, true); - } else { - vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, false); - this._context.workspaceState.update(FOCUS_REVIEW_MODE, false); - } - } - - private async clear(quitReviewMode: boolean) { - if (quitReviewMode) { - const activePullRequest = this._folderRepoManager.activePullRequest; - if (activePullRequest) { - this._activePrViewCoordinator.removePullRequest(activePullRequest); - } - - if (this.changesInPrDataProvider) { - await this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); - } - - this._prNumber = undefined; - this._folderRepoManager.activePullRequest = undefined; - - if (this._statusBarItem) { - this._statusBarItem.hide(); - } - - this._updateMessageShown = false; - this._reviewModel.clear(); - - this._localToDispose.forEach(disposable => disposable.dispose()); - // Ensure file explorer decorations are removed. When switching to a different PR branch, - // comments are recalculated when getting the data and the change decoration fired then, - // so comments only needs to be emptied in this case. - activePullRequest?.clear(); - this._folderRepoManager.setFileViewedContext(); - this._validateStatusInProgress = undefined; - } - - this._reviewCommentController?.dispose(); - this._reviewCommentController = undefined; - this._inMemGitHubContentProvider?.dispose(); - this._inMemGitHubContentProvider = undefined; - } - - async provideTextDocumentContent(uri: vscode.Uri): Promise { - const { path, commit, base } = fromReviewUri(uri.query); - let changedItems = gitFileChangeNodeFilter(this._reviewModel.localFileChanges) - .filter(change => change.fileName === path) - .filter( - fileChange => - fileChange.sha === commit || - `${fileChange.sha}^` === commit, - ); - - if (changedItems.length) { - const changedItem = changedItems[0]; - const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; - const ret = (await changedItem.changeModel.diffHunks()).map(diffHunk => - diffHunk.diffLines - .filter(diffLine => diffLine.type !== diffChangeTypeFilter) - .map(diffLine => diffLine.text), - ); - return ret.reduce((prev, curr) => prev.concat(...curr), []).join('\n'); - } - - changedItems = gitFileChangeNodeFilter(this._reviewModel.obsoleteFileChanges) - .filter(change => change.fileName === path) - .filter( - fileChange => - fileChange.sha === commit || - `${fileChange.sha}^` === commit, - ); - - if (changedItems.length) { - // it's from obsolete file changes, which means the content is in complete. - const changedItem = changedItems[0]; - const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; - const ret: string[] = []; - const commentGroups = groupBy(changedItem.comments, comment => String(comment.originalPosition)); - - for (const comment_position in commentGroups) { - if (!commentGroups[comment_position][0].diffHunks) { - continue; - } - - const lines = commentGroups[comment_position][0] - .diffHunks!.map(diffHunk => - diffHunk.diffLines - .filter(diffLine => diffLine.type !== diffChangeTypeFilter) - .map(diffLine => diffLine.text), - ) - .reduce((prev, curr) => prev.concat(...curr), []); - ret.push(...lines); - } - - return ret.join('\n'); - } else if (base && commit && this._folderRepoManager.activePullRequest) { - // We can't get the content from git. Try to get it from github. - const content = await getGitHubFileContent(this._folderRepoManager.activePullRequest.githubRepository, path, commit); - return content.toString(); - } - } - - dispose() { - this.clear(true); - dispose(this._disposables); - } - - static getReviewManagerForRepository( - reviewManagers: ReviewManager[], - githubRepository: GitHubRepository, - repository?: Repository - ): ReviewManager | undefined { - return reviewManagers.find(reviewManager => - reviewManager._folderRepoManager.gitHubRepositories.some(repo => { - // If we don't have a Repository, then just get the first GH repo that fits - // Otherwise, try to pick the review manager with the same repository. - return repo.equals(githubRepository) && (!repository || (reviewManager._folderRepoManager.repository === repository)); - }) - ); - } - - static getReviewManagerForFolderManager( - reviewManagers: ReviewManager[], - folderManager: FolderRepositoryManager, - ): ReviewManager | undefined { - return reviewManagers.find(reviewManager => reviewManager._folderRepoManager === folderManager); - } -} - -export class ShowPullRequest { - private _shouldShow: boolean = false; - private _onChangedShowValue: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onChangedShowValue: vscode.Event = this._onChangedShowValue.event; - constructor() { } - get shouldShow(): boolean { - return this._shouldShow; - } - set shouldShow(shouldShow: boolean) { - const oldShowValue = this._shouldShow; - this._shouldShow = shouldShow; - if (oldShowValue !== this._shouldShow) { - this._onChangedShowValue.fire(this._shouldShow); - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as nodePath from 'path'; +import * as vscode from 'vscode'; +import type { Branch, Repository } from '../api/api'; +import { GitApiImpl, GitErrorCodes } from '../api/api1'; +import { openDescription } from '../commands'; +import { DiffChangeType } from '../common/diffHunk'; +import { commands } from '../common/executeCommands'; +import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; +import Logger from '../common/logger'; +import { parseRepositoryRemotes, Remote } from '../common/remote'; +import { + COMMENTS, + FOCUSED_MODE, + IGNORE_PR_BRANCHES, + NEVER_IGNORE_DEFAULT_BRANCH, + OPEN_VIEW, + POST_CREATE, + PR_SETTINGS_NAMESPACE, + QUICK_DIFF, + USE_REVIEW_MODE, +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { fromPRUri, fromReviewUri, KnownMediaExtensions, PRUriParams, Schemes, toReviewUri } from '../common/uri'; +import { dispose, formatError, groupBy, isPreRelease, onceEvent } from '../common/utils'; +import { FOCUS_REVIEW_MODE } from '../constants'; +import { GitHubCreatePullRequestLinkProvider } from '../github/createPRLinkProvider'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository, ViewerPermission } from '../github/githubRepository'; +import { GithubItemStateEnum } from '../github/interface'; +import { PullRequestGitHelper, PullRequestMetadata } from '../github/pullRequestGitHelper'; +import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; +import { CreatePullRequestHelper } from './createPullRequestHelper'; +import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { getGitHubFileContent } from './gitHubContentProvider'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; +import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; +import { ProgressHelper } from './progress'; +import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; +import { RemoteQuickPickItem } from './quickpick'; +import { ReviewCommentController } from './reviewCommentController'; +import { ReviewModel } from './reviewModel'; +import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; +import { WebviewViewCoordinator } from './webviewViewCoordinator'; + +export class ReviewManager { + public static ID = 'Review'; + private _localToDispose: vscode.Disposable[] = []; + private _disposables: vscode.Disposable[]; + + private _reviewModel: ReviewModel = new ReviewModel(); + private _lastCommitSha?: string; + private _updateMessageShown: boolean = false; + private _validateStatusInProgress?: Promise; + private _reviewCommentController: ReviewCommentController | undefined; + private _quickDiffProvider: vscode.Disposable | undefined; + private _inMemGitHubContentProvider: vscode.Disposable | undefined; + + private _statusBarItem: vscode.StatusBarItem; + private _prNumber?: number; + private _isShowingLastReviewChanges: boolean = false; + private _previousRepositoryState: { + HEAD: Branch | undefined; + remotes: Remote[]; + }; + + private _switchingToReviewMode: boolean; + private _changesSinceLastReviewProgress: ProgressHelper = new ProgressHelper(); + /** + * Flag set when the "Checkout" action is used and cleared on the next git + * state update, once review mode has been entered. Used to disambiguate + * explicit user action from something like reloading on an existing PR branch. + */ + private justSwitchedToReviewMode: boolean = false; + + public get switchingToReviewMode(): boolean { + return this._switchingToReviewMode; + } + + public set switchingToReviewMode(newState: boolean) { + this._switchingToReviewMode = newState; + if (!newState) { + this.updateState(true); + } + } + + private _isFirstLoad = true; + + constructor( + private _id: number, + private _context: vscode.ExtensionContext, + private readonly _repository: Repository, + private _folderRepoManager: FolderRepositoryManager, + private _telemetry: ITelemetry, + public changesInPrDataProvider: PullRequestChangesTreeDataProvider, + private _pullRequestsTree: PullRequestsTreeDataProvider, + private _showPullRequest: ShowPullRequest, + private readonly _activePrViewCoordinator: WebviewViewCoordinator, + private _createPullRequestHelper: CreatePullRequestHelper, + gitApi: GitApiImpl + ) { + this._switchingToReviewMode = false; + this._disposables = []; + + this._previousRepositoryState = { + HEAD: _repository.state.HEAD, + remotes: parseRepositoryRemotes(this._repository), + }; + + this.registerListeners(); + + if (gitApi.state === 'initialized') { + this.updateState(true); + } + this.pollForStatusChange(); + } + + private registerListeners(): void { + this._disposables.push( + this._repository.state.onDidChange(_ => { + const oldHead = this._previousRepositoryState.HEAD; + const newHead = this._repository.state.HEAD; + + if (!oldHead && !newHead) { + // both oldHead and newHead are undefined + return; + } + + let sameUpstream: boolean | undefined; + + if (!oldHead || !newHead) { + sameUpstream = false; + } else { + sameUpstream = !!oldHead.upstream + ? newHead.upstream && + oldHead.upstream.name === newHead.upstream.name && + oldHead.upstream.remote === newHead.upstream.remote + : !newHead.upstream; + } + + const sameHead = + sameUpstream && // falsy if oldHead or newHead is undefined. + oldHead!.ahead === newHead!.ahead && + oldHead!.behind === newHead!.behind && + oldHead!.commit === newHead!.commit && + oldHead!.name === newHead!.name && + oldHead!.remote === newHead!.remote && + oldHead!.type === newHead!.type; + + const remotes = parseRepositoryRemotes(this._repository); + const sameRemotes = + this._previousRepositoryState.remotes.length === remotes.length && + this._previousRepositoryState.remotes.every(remote => remotes.some(r => remote.equals(r))); + + if (!sameHead || !sameRemotes) { + this._previousRepositoryState = { + HEAD: this._repository.state.HEAD, + remotes: remotes, + }; + + // The first time this event occurs we do want to do visible updates. + // The first time, oldHead will be undefined. + // For subsequent changes, we don't want to make visible updates. + // This occurs on branch changes. + // Note that the visible changes will occur when checking out a PR. + this.updateState(true); + } + + if (oldHead && newHead) { + this.updateBaseBranchMetadata(oldHead, newHead); + } + }), + ); + + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + this.updateFocusedViewMode(); + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${IGNORE_PR_BRANCHES}`)) { + this.validateState(true, false); + } + }), + ); + + + + this._disposables.push(this._folderRepoManager.onDidChangeActivePullRequest(_ => { + this.updateFocusedViewMode(); + this.registerQuickDiff(); + + + + + })); + + GitHubCreatePullRequestLinkProvider.registerProvider(this._disposables, this, this._folderRepoManager); + } + + private async updateBaseBranchMetadata(oldHead: Branch, newHead: Branch) { + if (!oldHead.commit || (oldHead.commit !== newHead.commit) || !newHead.name || !oldHead.name || (oldHead.name === newHead.name)) { + return; + } + + let githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === oldHead.upstream?.remote); + if (githubRepository) { + const metadata = await githubRepository.getMetadata(); + if (metadata.fork && oldHead.name === metadata.default_branch) { + // For forks, we use the upstream repo if it's available. Otherwise, fallback to the fork. + githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.owner === metadata.parent?.owner?.login && repo.remote.repositoryName === metadata.parent?.name) ?? githubRepository; + } + return PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, newHead.name, githubRepository.remote.owner, githubRepository.remote.repositoryName, oldHead.name); + } + } + + private registerQuickDiff() { + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(QUICK_DIFF)) { + if (this._quickDiffProvider) { + this._quickDiffProvider.dispose(); + this._quickDiffProvider = undefined; + } + const label = this._folderRepoManager.activePullRequest ? vscode.l10n.t('GitHub pull request #{0}', this._folderRepoManager.activePullRequest.number) : vscode.l10n.t('GitHub pull request'); + this._disposables.push(this._quickDiffProvider = vscode.window.registerQuickDiffProvider({ scheme: 'file' }, { + provideOriginalResource: (uri: vscode.Uri) => { + const changeNode = this.reviewModel.localFileChanges.find(changeNode => changeNode.changeModel.filePath.toString() === uri.toString()); + if (changeNode) { + return changeNode.changeModel.parentFilePath; + } + } + }, label, this.repository.rootUri)); + } + } + + + get statusBarItem() { + if (!this._statusBarItem) { + this._statusBarItem = vscode.window.createStatusBarItem('github.pullrequest.status', vscode.StatusBarAlignment.Left); + this._statusBarItem.name = vscode.l10n.t('GitHub Active Pull Request'); + } + + return this._statusBarItem; + } + + get repository(): Repository { + return this._repository; + } + + get reviewModel() { + return this._reviewModel; + } + + private pollForStatusChange() { + setTimeout(async () => { + if (!this._validateStatusInProgress) { + await this.updateComments(); + } + this.pollForStatusChange(); + }, 1000 * 60 * 5); + } + + private get id(): string { + return `${ReviewManager.ID}+${this._id}`; + } + + public async updateState(silent: boolean = false, updateLayout: boolean = true) { + if (this.switchingToReviewMode) { + return; + } + if (!this._validateStatusInProgress) { + Logger.appendLine('Validate state in progress', this.id); + this._validateStatusInProgress = this.validateStatueAndSetContext(silent, updateLayout); + return this._validateStatusInProgress; + } else { + Logger.appendLine('Queuing additional validate state', this.id); + this._validateStatusInProgress = this._validateStatusInProgress.then(async _ => { + return await this.validateStatueAndSetContext(silent, updateLayout); + }); + + return this._validateStatusInProgress; + } + } + + private hasShownLogRequest: boolean = false; + private async validateStatueAndSetContext(silent: boolean, updateLayout: boolean) { + // TODO @alexr00: There's a bug where validateState never returns sometimes. It's not clear what's causing this. + // This is a temporary workaround to ensure that the validateStatueAndSetContext promise always resolves. + // Additional logs have been added, and the issue is being tracked here: https://github.com/microsoft/vscode-pull-request-git/issues/5277 + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise(resolve => { + timeout = setTimeout(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + Logger.error('Timeout occurred while validating state.', this.id); + if (!this.hasShownLogRequest && isPreRelease(this._context)) { + this.hasShownLogRequest = true; + vscode.window.showErrorMessage(vscode.l10n.t('A known error has occurred refreshing the repository state. Please share logs from "GitHub Pull Request" in the [tracking issue]({0}).', 'https://github.com/microsoft/vscode-pull-request-github/issues/5277')); + } + } + resolve(); + }, 1000 * 60 * 2); + }); + + const validatePromise = new Promise(resolve => { + this.validateState(silent, updateLayout).then(() => { + vscode.commands.executeCommand('setContext', 'github:stateValidated', true).then(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + resolve(); + }); + }); + }); + + return Promise.race([validatePromise, timeoutPromise]); + } + + private async offerIgnoreBranch(currentBranchName): Promise { + const ignoreBranchStateKey = 'githubPullRequest.showOfferIgnoreBranch'; + const showOffer = this._context.workspaceState.get(ignoreBranchStateKey, true); + if (!showOffer) { + return false; + } + // Only show once per day. + const lastOfferTimeKey = 'githubPullRequest.offerIgnoreBranchTime'; + const lastOfferTime = this._context.workspaceState.get(lastOfferTimeKey, 0); + const currentTime = new Date().getTime(); + if ((currentTime - lastOfferTime) < (1000 * 60 * 60 * 24)) { // 1 day + return false; + } + const { base } = await this._folderRepoManager.getPullRequestDefaults(currentBranchName); + if (base !== currentBranchName) { + return false; + } + await this._context.workspaceState.update(lastOfferTimeKey, currentTime); + const ignore = vscode.l10n.t('Ignore Pull Request'); + const dontShow = vscode.l10n.t('Don\'t Show Again'); + const offerResult = await vscode.window.showInformationMessage( + vscode.l10n.t(`There\'s a pull request associated with the default branch '{0}'. Do you want to ignore this Pull Request?`, currentBranchName), + ignore, + dontShow); + if (offerResult === ignore) { + Logger.appendLine(`Branch ${currentBranchName} will now be ignored in ${IGNORE_PR_BRANCHES}.`, this.id); + const settingNamespace = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const setting = settingNamespace.get(IGNORE_PR_BRANCHES, []); + setting.push(currentBranchName); + await settingNamespace.update(IGNORE_PR_BRANCHES, setting); + return true; + } else if (offerResult === dontShow) { + await this._context.workspaceState.update(ignoreBranchStateKey, false); + return false; + } + return false; + } + + private async getUpstreamUrlAndName(branch: Branch): Promise<{ url: string | undefined, branchName: string | undefined, remoteName: string | undefined }> { + if (branch.upstream) { + return { remoteName: branch.upstream.remote, branchName: branch.upstream.name, url: undefined }; + } else { + try { + const url = await this.repository.getConfig(`branch.${branch.name}.remote`); + const upstreamBranch = await this.repository.getConfig(`branch.${branch.name}.merge`); + let branchName: string | undefined; + if (upstreamBranch) { + branchName = upstreamBranch.substring('refs/heads/'.length); + } + return { url, branchName, remoteName: undefined }; + } catch (e) { + Logger.appendLine(`Failed to get upstream for branch ${branch.name} from git config.`, this.id); + return { url: undefined, branchName: undefined, remoteName: undefined }; + } + } + } + + private async checkGitHubForPrBranch(branch: Branch): Promise<(PullRequestMetadata & { model: PullRequestModel }) | undefined> { + const { url, branchName, remoteName } = await this.getUpstreamUrlAndName(this._repository.state.HEAD!); + const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(branch, remoteName, url, branchName); + if (metadataFromGithub) { + Logger.appendLine(`Found matching pull request metadata on GitHub for current branch ${branch.name}. Repo: ${metadataFromGithub.owner}/${metadataFromGithub.repositoryName} PR: ${metadataFromGithub.prNumber}`); + await PullRequestGitHelper.associateBranchWithPullRequest( + this._repository, + metadataFromGithub.model, + branch.name!, + ); + return metadataFromGithub; + } + } + + private async resolvePullRequest(metadata: PullRequestMetadata): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { + try { + this._prNumber = metadata.prNumber; + + const { owner, repositoryName } = metadata; + Logger.appendLine('Resolving pull request', this.id); + const pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber); + + if (!pr || !pr.isResolved()) { + await this.clear(true); + this._prNumber = undefined; + Logger.appendLine('This PR is no longer valid', this.id); + return; + } + return pr; + } catch (e) { + Logger.appendLine(`Pull request cannot be resolved: ${e.message}`, this.id); + } + } + + private async validateState(silent: boolean, updateLayout: boolean) { + Logger.appendLine('Validating state...', this.id); + const oldLastCommitSha = this._lastCommitSha; + this._lastCommitSha = undefined; + await this._folderRepoManager.updateRepositories(false); + + if (!this._repository.state.HEAD) { + await this.clear(true); + return; + } + + const branch = this._repository.state.HEAD; + const ignoreBranches = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(IGNORE_PR_BRANCHES); + if (ignoreBranches?.find(value => value === branch.name) && ((branch.remote === 'origin') || !(await this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === branch.remote)?.getMetadata())?.fork)) { + Logger.appendLine(`Branch ${branch.name} is ignored in ${IGNORE_PR_BRANCHES}.`, this.id); + await this.clear(true); + return; + } + + let matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); + + if (!matchingPullRequestMetadata) { + Logger.appendLine(`No matching pull request metadata found locally for current branch ${branch.name}`, this.id); + matchingPullRequestMetadata = await this.checkGitHubForPrBranch(branch); + } + + if (!matchingPullRequestMetadata) { + Logger.appendLine( + `No matching pull request metadata found on GitHub for current branch ${branch.name}`, this.id + ); + await this.clear(true); + return; + } + Logger.appendLine(`Found matching pull request metadata for current branch ${branch.name}. Repo: ${matchingPullRequestMetadata.owner}/${matchingPullRequestMetadata.repositoryName} PR: ${matchingPullRequestMetadata.prNumber}`, this.id); + + const remote = branch.upstream ? branch.upstream.remote : null; + if (!remote) { + Logger.appendLine(`Current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`, this.id); + await this.clear(true); + return; + } + + // we switch to another PR, let's clean up first. + Logger.appendLine( + `current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, this.id + ); + const previousPrNumber = this._prNumber; + let pr = await this.resolvePullRequest(matchingPullRequestMetadata); + if (!pr) { + Logger.appendLine(`Unable to resolve PR #${matchingPullRequestMetadata.prNumber}`, this.id); + return; + } + Logger.appendLine(`Resolved PR #${matchingPullRequestMetadata.prNumber}, state is ${pr.state}`, this.id); + + // Check if the PR is open, if not, check if there's another PR from the same branch on GitHub + if (pr.state !== GithubItemStateEnum.Open) { + const metadataFromGithub = await this.checkGitHubForPrBranch(branch); + if (metadataFromGithub && metadataFromGithub?.prNumber !== pr.number) { + const prFromGitHub = await this.resolvePullRequest(metadataFromGithub); + if (prFromGitHub) { + pr = prFromGitHub; + } + } + } + + const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; + if (previousPrNumber === pr.number && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { + this._validateStatusInProgress = undefined; + return; + } + this._isShowingLastReviewChanges = pr.showChangesSinceReview; + if (previousPrNumber !== pr.number) { + this.clear(false); + } + + const useReviewConfiguration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) + .get<{ merged: boolean, closed: boolean }>(USE_REVIEW_MODE, { merged: true, closed: false }); + + if (pr.isClosed && !useReviewConfiguration.closed) { + Logger.appendLine('This PR is closed', this.id); + await this.clear(true); + return; + } + + if (pr.isMerged && !useReviewConfiguration.merged) { + Logger.appendLine('This PR is merged', this.id); + await this.clear(true); + return; + } + + const neverIgnoreDefaultBranch = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NEVER_IGNORE_DEFAULT_BRANCH, false); + if (!neverIgnoreDefaultBranch) { + // Do not await the result of offering to ignore the branch. + this.offerIgnoreBranch(branch.name); + } + + const previousActive = this._folderRepoManager.activePullRequest; + this._folderRepoManager.activePullRequest = pr; + this._lastCommitSha = pr.head.sha; + + if (this._isFirstLoad) { + this._isFirstLoad = false; + this._folderRepoManager.checkBranchUpToDate(pr, true); + } + + Logger.appendLine('Fetching pull request data', this.id); + if (!silent) { + onceEvent(this._reviewModel.onDidChangeLocalFileChanges)(() => { + if (pr) { + this._upgradePullRequestEditors(pr); + } + }); + } + // Don't await. Events will be fired as part of the initialization. + this.initializePullRequestData(pr); + await this.changesInPrDataProvider.addPrToView( + this._folderRepoManager, + pr, + this._reviewModel, + this.justSwitchedToReviewMode, + this._changesSinceLastReviewProgress + ); + + Logger.appendLine(`Register comments provider`, this.id); + await this.registerCommentController(); + + this._activePrViewCoordinator.setPullRequest(pr, this._folderRepoManager, this, previousActive); + this._localToDispose.push( + pr.onDidChangeChangesSinceReview(async _ => { + this._changesSinceLastReviewProgress.startProgress(); + this.changesInPrDataProvider.refresh(); + await this.updateComments(); + await this.reopenNewReviewDiffs(); + this._changesSinceLastReviewProgress.endProgress(); + }) + ); + Logger.appendLine(`Register in memory content provider`, this.id); + await this.registerGitHubInMemContentProvider(); + + this.statusBarItem.text = '$(git-pull-request) ' + vscode.l10n.t('Pull Request #{0}', pr.number); + this.statusBarItem.command = { + command: 'pr.openDescription', + title: vscode.l10n.t('View Pull Request Description'), + arguments: [pr], + }; + Logger.appendLine(`Display pull request status bar indicator.`, this.id); + this.statusBarItem.show(); + + this.layout(pr, updateLayout, this.justSwitchedToReviewMode ? false : silent); + this.justSwitchedToReviewMode = false; + + this._validateStatusInProgress = undefined; + } + + private layout(pr: PullRequestModel, updateLayout: boolean, silent: boolean) { + const isFocusMode = this._context.workspaceState.get(FOCUS_REVIEW_MODE); + + Logger.appendLine(`Using focus mode = ${isFocusMode}.`, this.id); + Logger.appendLine(`State validation silent = ${silent}.`, this.id); + Logger.appendLine(`PR show should show = ${this._showPullRequest.shouldShow}.`, this.id); + + if ((!silent || this._showPullRequest.shouldShow) && isFocusMode) { + this._doFocusShow(pr, updateLayout); + } else if (!this._showPullRequest.shouldShow && isFocusMode) { + const showPRChangedDisposable = this._showPullRequest.onChangedShowValue(shouldShow => { + Logger.appendLine(`PR show value changed = ${shouldShow}.`, this.id); + if (shouldShow) { + this._doFocusShow(pr, updateLayout); + } + showPRChangedDisposable.dispose(); + }); + this._localToDispose.push(showPRChangedDisposable); + } + } + + private async reopenNewReviewDiffs() { + let hasOpenDiff = false; + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Review)) { + + for (const localChange of this._reviewModel.localFileChanges) { + const fileName = fromReviewUri(tab.input.original.query); + + if (localChange.fileName === fileName.path) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderRepoManager, { preview: tab.isPreview })); + break; + } + } + + } + } + return Promise.resolve(undefined); + }); + }).flat()); + + if (!hasOpenDiff && this._reviewModel.localFileChanges.length) { + this._reviewModel.localFileChanges[0].openDiff(this._folderRepoManager, { preview: true }); + } + } + + private openDiff() { + if (this._reviewModel.localFileChanges.length) { + let fileChangeToShow: GitFileChangeNode[] = []; + for (const fileChange of this._reviewModel.localFileChanges) { + if (fileChange.status === GitChangeType.MODIFY) { + if (KnownMediaExtensions.includes(nodePath.extname(fileChange.fileName))) { + fileChangeToShow.push(fileChange); + } else { + fileChangeToShow.unshift(fileChange); + break; + } + } + } + const change = fileChangeToShow.length ? fileChangeToShow[0] : this._reviewModel.localFileChanges[0]; + change.openDiff(this._folderRepoManager); + } + } + + private _doFocusShow(pr: PullRequestModel, updateLayout: boolean) { + // Respect the setting 'comments.openView' when it's 'never'. + const shouldShowCommentsView = vscode.workspace.getConfiguration(COMMENTS).get<'never' | string>(OPEN_VIEW); + if (shouldShowCommentsView !== 'never') { + commands.executeCommand('workbench.action.focusCommentsPanel'); + } + this._activePrViewCoordinator.show(pr); + if (updateLayout) { + const focusedMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'firstDiff' | 'overview' | 'multiDiff' | false>(FOCUSED_MODE); + if (focusedMode === 'firstDiff') { + if (this._reviewModel.localFileChanges.length) { + this.openDiff(); + } else { + const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { + localFileChangesDisposable.dispose(); + this.openDiff(); + }); + } + } else if (focusedMode === 'overview') { + return this.openDescription(); + } else if (focusedMode === 'multiDiff') { + return PullRequestModel.openChanges(this._folderRepoManager, pr); + } + } + } + + public async _upgradePullRequestEditors(pullRequest: PullRequestModel) { + // Go through all open editors and find pr scheme editors that belong to the active pull request. + // Close the editors, and reopen them from the pull request. + const reopenFilenames: Set<[PRUriParams, PRUriParams]> = new Set(); + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Pr) && (tab.input.modified.scheme === Schemes.Pr)) { + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + if ((originalParams?.prNumber === pullRequest.number) && (modifiedParams?.prNumber === pullRequest.number)) { + reopenFilenames.add([originalParams, modifiedParams]); + return vscode.window.tabGroups.close(tab); + } + } + } + return Promise.resolve(undefined); + }); + }).flat()); + const reopenPromises: Promise[] = []; + if (reopenFilenames.size) { + for (const localChange of this.reviewModel.localFileChanges) { + for (const prFileChange of reopenFilenames) { + if (Array.isArray(prFileChange)) { + const modifiedPrChange = prFileChange[1]; + if (localChange.fileName === modifiedPrChange.fileName) { + reopenPromises.push(localChange.openDiff(this._folderRepoManager, { preview: false })); + reopenFilenames.delete(prFileChange); + break; + } + } + } + } + } + return Promise.all(reopenPromises); + } + + public async updateComments(): Promise { + const branch = this._repository.state.HEAD; + if (!branch) { + return; + } + + const matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); + if (!matchingPullRequestMetadata) { + return; + } + + const remote = branch.upstream ? branch.upstream.remote : null; + if (!remote) { + return; + } + + if (this._prNumber === undefined || !this._folderRepoManager.activePullRequest) { + return; + } + + const pr = await this._folderRepoManager.resolvePullRequest( + matchingPullRequestMetadata.owner, + matchingPullRequestMetadata.repositoryName, + this._prNumber, + ); + + if (!pr || !pr.isResolved()) { + Logger.warn('This PR is no longer valid', this.id); + return; + } + + await this._folderRepoManager.checkBranchUpToDate(pr, false); + + await this.initializePullRequestData(pr); + await this._reviewCommentController?.update(); + + return Promise.resolve(void 0); + } + + private async getLocalChangeNodes( + pr: PullRequestModel & IResolvedPullRequestModel, + contentChanges: (InMemFileChange | SlimFileChange)[], + ): Promise { + const nodes: GitFileChangeNode[] = []; + const mergeBase = pr.mergeBase || pr.base.sha; + const headSha = pr.head.sha; + + for (let i = 0; i < contentChanges.length; i++) { + const change = contentChanges[i]; + const filePath = nodePath.join(this._repository.rootUri.path, change.fileName).replace(/\\/g, '/'); + const uri = this._repository.rootUri.with({ path: filePath }); + + const modifiedFileUri = + change.status === GitChangeType.DELETE + ? toReviewUri(uri, undefined, undefined, '', false, { base: false }, this._repository.rootUri) + : uri; + + const originalFileUri = toReviewUri( + uri, + change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, + undefined, + change.status === GitChangeType.ADD ? '' : mergeBase, + false, + { base: true }, + this._repository.rootUri, + ); + + const changeModel = new GitFileChangeModel(this._folderRepoManager, pr, change, modifiedFileUri, originalFileUri, headSha, contentChanges.length < 20); + const changedItem = new GitFileChangeNode( + this.changesInPrDataProvider, + this._folderRepoManager, + pr, + changeModel + ); + nodes.push(changedItem); + } + + return nodes; + } + + private async initializePullRequestData(pr: PullRequestModel & IResolvedPullRequestModel): Promise { + try { + const contentChanges = await pr.getFileChangesInfo(); + this._reviewModel.localFileChanges = await this.getLocalChangeNodes(pr, contentChanges); + await Promise.all([pr.initializeReviewComments(), pr.initializeReviewThreadCache(), pr.initializePullRequestFileViewState()]); + this._folderRepoManager.setFileViewedContext(); + const outdatedComments = pr.comments.filter(comment => !comment.position); + + const commitsGroup = groupBy(outdatedComments, comment => comment.originalCommitId!); + const obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; + for (const commit in commitsGroup) { + const commentsForCommit = commitsGroup[commit]; + const commentsForFile = groupBy(commentsForCommit, comment => comment.path!); + + for (const fileName in commentsForFile) { + const oldComments = commentsForFile[fileName]; + const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, fileName)); + const changeModel = new GitFileChangeModel( + this._folderRepoManager, + pr, + { + status: GitChangeType.MODIFY, + fileName, + blobUrl: undefined, + + }, toReviewUri( + uri, + fileName, + undefined, + oldComments[0].originalCommitId!, + true, + { base: false }, + this._repository.rootUri, + ), + toReviewUri( + uri, + fileName, + undefined, + oldComments[0].originalCommitId!, + true, + { base: true }, + this._repository.rootUri, + ), + commit); + const obsoleteFileChange = new GitFileChangeNode( + this.changesInPrDataProvider, + this._folderRepoManager, + pr, + changeModel, + false, + oldComments + ); + + obsoleteFileChanges.push(obsoleteFileChange); + } + } + this._reviewModel.obsoleteFileChanges = obsoleteFileChanges; + + return Promise.resolve(void 0); + } catch (e) { + Logger.error(`Failed to initialize PR data ${e}`, this.id); + } + } + + private async registerGitHubInMemContentProvider() { + try { + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; + + const pr = this._folderRepoManager.activePullRequest; + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(this._folderRepoManager, change, pr); + } + return new InMemFileChangeModel(this._folderRepoManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, true, mergeBase); + }); + + this._inMemGitHubContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( + pr.number, + async (uri: vscode.Uri): Promise => { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + const fileChange = changes.find( + contentChange => contentChange.fileName === params.fileName, + ); + + if (!fileChange) { + Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(this._folderRepoManager, pr, params, fileChange); + + }, + ); + } catch (e) { + Logger.error(`Failed to register in mem content provider: ${e}`, this.id); + } + } + + private async registerCommentController() { + if (this._folderRepoManager.activePullRequest?.reviewThreadsCacheReady && this._reviewModel.hasLocalFileChanges) { + await this.doRegisterCommentController(); + } else { + const changedLocalFilesChangesDisposable: vscode.Disposable | undefined = + this._reviewModel.onDidChangeLocalFileChanges(async () => { + if (this._folderRepoManager.activePullRequest?.reviewThreadsCache && this._reviewModel.hasLocalFileChanges) { + if (changedLocalFilesChangesDisposable) { + changedLocalFilesChangesDisposable.dispose(); + } + await this.doRegisterCommentController(); + } + }); + } + } + + private async doRegisterCommentController() { + if (!this._reviewCommentController) { + this._reviewCommentController = new ReviewCommentController( + this, + this._folderRepoManager, + this._repository, + this._reviewModel, + ); + + await this._reviewCommentController.initialize(); + } + } + + public async switch(pr: PullRequestModel): Promise { + Logger.appendLine(`Switch to Pull Request #${pr.number} - start`, this.id); + this.statusBarItem.text = vscode.l10n.t('{0} Switching to Review Mode', '$(sync~spin)'); + this.statusBarItem.command = undefined; + this.statusBarItem.show(); + this.switchingToReviewMode = true; + + try { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { + const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr, progress); + + if (!didLocalCheckout) { + await this._folderRepoManager.fetchAndCheckout(pr, progress); + } + }); + } catch (e) { + Logger.error(`Checkout failed #${JSON.stringify(e)}`, this.id); + this.switchingToReviewMode = false; + + if (e.message === 'User aborted') { + // The user cancelled the action + } else if (e.gitErrorCode && ( + e.gitErrorCode === GitErrorCodes.LocalChangesOverwritten || + e.gitErrorCode === GitErrorCodes.DirtyWorkTree + )) { + // for known git errors, we should provide actions for users to continue. + vscode.window.showErrorMessage(vscode.l10n.t( + 'Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches' + )); + } else if ((e.stderr as string)?.startsWith('fatal: couldn\'t find remote ref') && e.gitCommand === 'fetch') { + // The pull request was checked out, but the upstream branch was deleted + vscode.window.showInformationMessage('The remote branch for this pull request has been deleted. The file contents may not match the remote.'); + } else { + vscode.window.showErrorMessage(formatError(e)); + } + // todo, we should try to recover, for example, git checkout succeeds but set config fails. + if (this._folderRepoManager.activePullRequest) { + this.setStatusForPr(this._folderRepoManager.activePullRequest); + } else { + this.statusBarItem.hide(); + } + return; + } + + try { + this.statusBarItem.text = '$(sync~spin) ' + vscode.l10n.t('Fetching additional data: {0}', `pr/${pr.number}`); + this.statusBarItem.command = undefined; + this.statusBarItem.show(); + + await this._folderRepoManager.fulfillPullRequestMissingInfo(pr); + this._upgradePullRequestEditors(pr); + + /* __GDPR__ + "pr.checkout" : {} + */ + this._telemetry.sendTelemetryEvent('pr.checkout'); + Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, this.id); + } finally { + this.setStatusForPr(pr); + await this._repository.status(); + } + } + + private setStatusForPr(pr: PullRequestModel) { + this.switchingToReviewMode = false; + this.justSwitchedToReviewMode = true; + this.statusBarItem.text = vscode.l10n.t('Pull Request #{0}', pr.number); + this.statusBarItem.command = undefined; + this.statusBarItem.show(); + } + + public async publishBranch(branch: Branch): Promise { + const potentialTargetRemotes = await this._folderRepoManager.getAllGitHubRemotes(); + let selectedRemote = (await this.getRemote( + potentialTargetRemotes, + vscode.l10n.t(`Pick a remote to publish the branch '{0}' to:`, branch.name!), + ))!.remote; + + if (!selectedRemote || branch.name === undefined) { + return; + } + + const githubRepo = await this._folderRepoManager.createGitHubRepository( + selectedRemote, + this._folderRepoManager.credentialStore, + ); + const permission = await githubRepo.getViewerPermission(); + if ( + permission === ViewerPermission.Read || + permission === ViewerPermission.Triage || + permission === ViewerPermission.Unknown + ) { + // No permission to publish the branch to the chosen remote. Offer to fork. + const fork = await this._folderRepoManager.tryOfferToFork(githubRepo); + if (!fork) { + return; + } + selectedRemote = (await this._folderRepoManager.getGitHubRemotes()).find(element => element.remoteName === fork); + } + + if (!selectedRemote) { + return; + } + const remote: Remote = selectedRemote; + + return new Promise(async resolve => { + const inputBox = vscode.window.createInputBox(); + inputBox.value = branch.name!; + inputBox.ignoreFocusOut = true; + inputBox.prompt = + potentialTargetRemotes.length === 1 + ? vscode.l10n.t(`The branch '{0}' is not published yet, pick a name for the upstream branch`, branch.name!) + : vscode.l10n.t('Pick a name for the upstream branch'); + const validate = async function (value: string) { + try { + inputBox.busy = true; + const remoteBranch = await this._reposManager.getBranch(remote, value); + if (remoteBranch) { + inputBox.validationMessage = vscode.l10n.t(`Branch '{0}' already exists in {1}`, value, `${remote.owner}/${remote.repositoryName}`); + } else { + inputBox.validationMessage = undefined; + } + } catch (e) { + inputBox.validationMessage = undefined; + } + + inputBox.busy = false; + }; + await validate(branch.name!); + inputBox.onDidChangeValue(validate.bind(this)); + inputBox.onDidAccept(async () => { + inputBox.validationMessage = undefined; + inputBox.hide(); + try { + // since we are probably pushing a remote branch with a different name, we use the complete syntax + // git push -u origin local_branch:remote_branch + await this._repository.push(remote.remoteName, `${branch.name}:${inputBox.value}`, true); + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.PushRejected) { + vscode.window.showWarningMessage( + vscode.l10n.t(`Can't push refs to remote, try running 'git pull' first to integrate with your change`), + { + modal: true, + }, + ); + + resolve(undefined); + } + + if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { + vscode.window.showWarningMessage( + vscode.l10n.t(`Could not read from remote repository '{0}'. Please make sure you have the correct access rights and the repository exists.`, remote.remoteName), + { + modal: true, + }, + ); + + resolve(undefined); + } + + // we can't handle the error + throw err; + } + + // we don't want to wait for repository status update + const latestBranch = await this._repository.getBranch(branch.name!); + if (!latestBranch || !latestBranch.upstream) { + resolve(undefined); + } + + resolve(latestBranch); + }); + + inputBox.show(); + }); + } + + private async getRemote( + potentialTargetRemotes: Remote[], + placeHolder: string, + defaultUpstream?: RemoteQuickPickItem, + ): Promise { + if (!potentialTargetRemotes.length) { + vscode.window.showWarningMessage(vscode.l10n.t(`No GitHub remotes found. Add a remote and try again.`)); + return; + } + + if (potentialTargetRemotes.length === 1 && !defaultUpstream) { + return RemoteQuickPickItem.fromRemote(potentialTargetRemotes[0]); + } + + if ( + potentialTargetRemotes.length === 1 && + defaultUpstream && + defaultUpstream.owner === potentialTargetRemotes[0].owner && + defaultUpstream.name === potentialTargetRemotes[0].repositoryName + ) { + return defaultUpstream; + } + + let defaultUpstreamWasARemote = false; + const picks: RemoteQuickPickItem[] = potentialTargetRemotes.map(remote => { + const remoteQuickPick = RemoteQuickPickItem.fromRemote(remote); + if (defaultUpstream) { + const { owner, name } = defaultUpstream; + remoteQuickPick.picked = remoteQuickPick.owner === owner && remoteQuickPick.name === name; + if (remoteQuickPick.picked) { + defaultUpstreamWasARemote = true; + } + } + return remoteQuickPick; + }); + if (!defaultUpstreamWasARemote && defaultUpstream) { + picks.unshift(defaultUpstream); + } + + const selected: RemoteQuickPickItem | undefined = await vscode.window.showQuickPick( + picks, + { + ignoreFocusOut: true, + placeHolder: placeHolder, + }, + ); + + if (!selected) { + return; + } + + return selected; + } + + public async createPullRequest(compareBranch?: string): Promise { + const postCreate = async (createdPR: PullRequestModel) => { + const postCreate = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch' | 'checkoutDefaultBranchAndShow' | 'checkoutDefaultBranchAndCopy'>(POST_CREATE, 'openOverview'); + if (postCreate === 'openOverview') { + const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); + await openDescription( + this._context, + this._telemetry, + createdPR, + descriptionNode, + this._folderRepoManager, + true + ); + } else if (postCreate.startsWith('checkoutDefaultBranch')) { + const defaultBranch = await this._folderRepoManager.getPullRequestRepositoryDefaultBranch(createdPR); + if (defaultBranch) { + if (postCreate === 'checkoutDefaultBranch') { + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); + } if (postCreate === 'checkoutDefaultBranchAndShow') { + await vscode.commands.executeCommand('pr:github.focus'); + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); + await this._pullRequestsTree.expandPullRequest(createdPR); + } else if (postCreate === 'checkoutDefaultBranchAndCopy') { + await Promise.all([ + this._folderRepoManager.checkoutDefaultBranch(defaultBranch), + vscode.env.clipboard.writeText(createdPR.html_url) + ]); + } + } + } + await this.updateState(false, false); + }; + + return this._createPullRequestHelper.create(this._telemetry, this._context.extensionUri, this._folderRepoManager, compareBranch, postCreate); + } + + public async openDescription(): Promise { + const pullRequest = this._folderRepoManager.activePullRequest; + if (!pullRequest) { + return; + } + + const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); + await openDescription( + this._context, + this._telemetry, + pullRequest, + descriptionNode, + this._folderRepoManager, + true + ); + } + + get isCreatingPullRequest() { + return this._createPullRequestHelper?.isCreatingPullRequest ?? false; + } + + private async updateFocusedViewMode(): Promise { + const focusedSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FOCUSED_MODE); + if (focusedSetting) { + vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); + await this._context.workspaceState.update(FOCUS_REVIEW_MODE, true); + } else { + vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, false); + this._context.workspaceState.update(FOCUS_REVIEW_MODE, false); + } + } + + private async clear(quitReviewMode: boolean) { + if (quitReviewMode) { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (activePullRequest) { + this._activePrViewCoordinator.removePullRequest(activePullRequest); + } + + if (this.changesInPrDataProvider) { + await this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); + } + + this._prNumber = undefined; + this._folderRepoManager.activePullRequest = undefined; + + if (this._statusBarItem) { + this._statusBarItem.hide(); + } + + this._updateMessageShown = false; + this._reviewModel.clear(); + + this._localToDispose.forEach(disposable => disposable.dispose()); + // Ensure file explorer decorations are removed. When switching to a different PR branch, + // comments are recalculated when getting the data and the change decoration fired then, + // so comments only needs to be emptied in this case. + activePullRequest?.clear(); + this._folderRepoManager.setFileViewedContext(); + this._validateStatusInProgress = undefined; + } + + this._reviewCommentController?.dispose(); + this._reviewCommentController = undefined; + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; + } + + async provideTextDocumentContent(uri: vscode.Uri): Promise { + const { path, commit, base } = fromReviewUri(uri.query); + let changedItems = gitFileChangeNodeFilter(this._reviewModel.localFileChanges) + .filter(change => change.fileName === path) + .filter( + fileChange => + fileChange.sha === commit || + `${fileChange.sha}^` === commit, + ); + + if (changedItems.length) { + const changedItem = changedItems[0]; + const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; + const ret = (await changedItem.changeModel.diffHunks()).map(diffHunk => + diffHunk.diffLines + .filter(diffLine => diffLine.type !== diffChangeTypeFilter) + .map(diffLine => diffLine.text), + ); + return ret.reduce((prev, curr) => prev.concat(...curr), []).join('\n'); + } + + changedItems = gitFileChangeNodeFilter(this._reviewModel.obsoleteFileChanges) + .filter(change => change.fileName === path) + .filter( + fileChange => + fileChange.sha === commit || + `${fileChange.sha}^` === commit, + ); + + if (changedItems.length) { + // it's from obsolete file changes, which means the content is in complete. + const changedItem = changedItems[0]; + const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; + const ret: string[] = []; + const commentGroups = groupBy(changedItem.comments, comment => String(comment.originalPosition)); + + for (const comment_position in commentGroups) { + if (!commentGroups[comment_position][0].diffHunks) { + continue; + } + + const lines = commentGroups[comment_position][0] + .diffHunks!.map(diffHunk => + diffHunk.diffLines + .filter(diffLine => diffLine.type !== diffChangeTypeFilter) + .map(diffLine => diffLine.text), + ) + .reduce((prev, curr) => prev.concat(...curr), []); + ret.push(...lines); + } + + return ret.join('\n'); + } else if (base && commit && this._folderRepoManager.activePullRequest) { + // We can't get the content from git. Try to get it from github. + const content = await getGitHubFileContent(this._folderRepoManager.activePullRequest.githubRepository, path, commit); + return content.toString(); + } + } + + dispose() { + this.clear(true); + dispose(this._disposables); + } + + static getReviewManagerForRepository( + reviewManagers: ReviewManager[], + githubRepository: GitHubRepository, + repository?: Repository + ): ReviewManager | undefined { + return reviewManagers.find(reviewManager => + reviewManager._folderRepoManager.gitHubRepositories.some(repo => { + // If we don't have a Repository, then just get the first GH repo that fits + // Otherwise, try to pick the review manager with the same repository. + return repo.equals(githubRepository) && (!repository || (reviewManager._folderRepoManager.repository === repository)); + }) + ); + } + + static getReviewManagerForFolderManager( + reviewManagers: ReviewManager[], + folderManager: FolderRepositoryManager, + ): ReviewManager | undefined { + return reviewManagers.find(reviewManager => reviewManager._folderRepoManager === folderManager); + } +} + +export class ShowPullRequest { + private _shouldShow: boolean = false; + private _onChangedShowValue: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onChangedShowValue: vscode.Event = this._onChangedShowValue.event; + constructor() { } + get shouldShow(): boolean { + return this._shouldShow; + } + set shouldShow(shouldShow: boolean) { + const oldShowValue = this._shouldShow; + this._shouldShow = shouldShow; + if (oldShowValue !== this._shouldShow) { + this._onChangedShowValue.fire(this._shouldShow); + } + } +} diff --git a/src/view/reviewModel.ts b/src/view/reviewModel.ts index 52478da7a5..f2439d7422 100644 --- a/src/view/reviewModel.ts +++ b/src/view/reviewModel.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; @@ -40,4 +41,4 @@ export class ReviewModel { this.obsoleteFileChanges = []; this._localFileChanges = undefined; } -} \ No newline at end of file +} diff --git a/src/view/reviewsManager.ts b/src/view/reviewsManager.ts index 38a51d4aef..de5f2b23f1 100644 --- a/src/view/reviewsManager.ts +++ b/src/view/reviewsManager.ts @@ -1,109 +1,110 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { ITelemetry } from '../common/telemetry'; -import { Schemes } from '../common/uri'; -import { CredentialStore } from '../github/credentials'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { GitContentFileSystemProvider } from './gitContentProvider'; -import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; -import { ReviewManager } from './reviewManager'; - -export class ReviewsManager { - public static ID = 'Reviews'; - private _disposables: vscode.Disposable[]; - - constructor( - private _context: vscode.ExtensionContext, - private _reposManager: RepositoriesManager, - private _reviewManagers: ReviewManager[], - private _prsTreeDataProvider: PullRequestsTreeDataProvider, - private _prFileChangesProvider: PullRequestChangesTreeDataProvider, - private _telemetry: ITelemetry, - private _credentialStore: CredentialStore, - private _gitApi: GitApiImpl, - ) { - this._disposables = []; - const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, _reviewManagers); - gitContentProvider.registerTextDocumentContentFallback(this.provideTextDocumentContent.bind(this)); - this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.Review, gitContentProvider, { isReadonly: true })); - this.registerListeners(); - this._disposables.push(this._prsTreeDataProvider); - } - - get reviewManagers(): ReviewManager[] { - return this._reviewManagers; - } - - private registerListeners(): void { - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration('githubPullRequests.showInSCM')) { - if (this._prFileChangesProvider) { - this._prFileChangesProvider.dispose(); - this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._context, this._gitApi, this._reposManager); - - for (const reviewManager of this._reviewManagers) { - reviewManager.updateState(true); - } - } - - this._prsTreeDataProvider.dispose(); - this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry, this._context, this._reposManager); - this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._credentialStore); - this._disposables.push(this._prsTreeDataProvider); - } - }), - ); - } - - async provideTextDocumentContent(uri: vscode.Uri): Promise { - for (const reviewManager of this._reviewManagers) { - if (uri.fsPath.startsWith(reviewManager.repository.rootUri.fsPath)) { - return reviewManager.provideTextDocumentContent(uri); - } - } - return ''; - } - - public addReviewManager(reviewManager: ReviewManager) { - // Try to insert in workspace folder order - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders) { - const index = workspaceFolders.findIndex( - folder => folder.uri.toString() === reviewManager.repository.rootUri.toString(), - ); - if (index > -1) { - const arrayEnd = this._reviewManagers.slice(index, this._reviewManagers.length); - this._reviewManagers = this._reviewManagers.slice(0, index); - this._reviewManagers.push(reviewManager); - this._reviewManagers.push(...arrayEnd); - return; - } - } - this._reviewManagers.push(reviewManager); - } - - public removeReviewManager(repo: Repository) { - const reviewManagerIndex = this._reviewManagers.findIndex( - manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), - ); - if (reviewManagerIndex >= 0) { - const manager = this._reviewManagers[reviewManagerIndex]; - this._reviewManagers.splice(reviewManagerIndex); - manager.dispose(); - } - } - - dispose() { - this._disposables.forEach(d => { - d.dispose(); - }); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { ITelemetry } from '../common/telemetry'; +import { Schemes } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { GitContentFileSystemProvider } from './gitContentProvider'; +import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; +import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; +import { ReviewManager } from './reviewManager'; + +export class ReviewsManager { + public static ID = 'Reviews'; + private _disposables: vscode.Disposable[]; + + constructor( + private _context: vscode.ExtensionContext, + private _reposManager: RepositoriesManager, + private _reviewManagers: ReviewManager[], + private _prsTreeDataProvider: PullRequestsTreeDataProvider, + private _prFileChangesProvider: PullRequestChangesTreeDataProvider, + private _telemetry: ITelemetry, + private _credentialStore: CredentialStore, + private _gitApi: GitApiImpl, + ) { + this._disposables = []; + const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, _reviewManagers); + gitContentProvider.registerTextDocumentContentFallback(this.provideTextDocumentContent.bind(this)); + this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.Review, gitContentProvider, { isReadonly: true })); + this.registerListeners(); + this._disposables.push(this._prsTreeDataProvider); + } + + get reviewManagers(): ReviewManager[] { + return this._reviewManagers; + } + + private registerListeners(): void { + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('githubPullRequests.showInSCM')) { + if (this._prFileChangesProvider) { + this._prFileChangesProvider.dispose(); + this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._context, this._gitApi, this._reposManager); + + for (const reviewManager of this._reviewManagers) { + reviewManager.updateState(true); + } + } + + this._prsTreeDataProvider.dispose(); + this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry, this._context, this._reposManager); + this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._credentialStore); + this._disposables.push(this._prsTreeDataProvider); + } + }), + ); + } + + async provideTextDocumentContent(uri: vscode.Uri): Promise { + for (const reviewManager of this._reviewManagers) { + if (uri.fsPath.startsWith(reviewManager.repository.rootUri.fsPath)) { + return reviewManager.provideTextDocumentContent(uri); + } + } + return ''; + } + + public addReviewManager(reviewManager: ReviewManager) { + // Try to insert in workspace folder order + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const index = workspaceFolders.findIndex( + folder => folder.uri.toString() === reviewManager.repository.rootUri.toString(), + ); + if (index > -1) { + const arrayEnd = this._reviewManagers.slice(index, this._reviewManagers.length); + this._reviewManagers = this._reviewManagers.slice(0, index); + this._reviewManagers.push(reviewManager); + this._reviewManagers.push(...arrayEnd); + return; + } + } + this._reviewManagers.push(reviewManager); + } + + public removeReviewManager(repo: Repository) { + const reviewManagerIndex = this._reviewManagers.findIndex( + manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), + ); + if (reviewManagerIndex >= 0) { + const manager = this._reviewManagers[reviewManagerIndex]; + this._reviewManagers.splice(reviewManagerIndex); + manager.dispose(); + } + } + + dispose() { + this._disposables.forEach(d => { + d.dispose(); + }); + } +} diff --git a/src/view/treeDecorationProvider.ts b/src/view/treeDecorationProvider.ts index b3d372e14c..77aa4f6719 100644 --- a/src/view/treeDecorationProvider.ts +++ b/src/view/treeDecorationProvider.ts @@ -1,45 +1,46 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { fromFileChangeNodeUri } from '../common/uri'; - -class TreeDecorationProvider implements vscode.FileDecorationProvider { - private fileHasComments: Map = new Map(); - - updateFileComments(resourceUri: vscode.Uri, prNumber: number, fileName: string, hasComments: boolean): void { - const key = `${prNumber}:${fileName}`; - const oldValue = this.fileHasComments.get(key); - if (oldValue !== hasComments) { - this.fileHasComments.set(`${prNumber}:${fileName}`, hasComments); - this._onDidChangeFileDecorations.fire(resourceUri); - } - } - - _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult { - const query = fromFileChangeNodeUri(uri); - if (query) { - const key = `${query.prNumber}:${query.fileName}`; - if (this.fileHasComments.get(key)) { - return { - propagate: false, - tooltip: 'Commented', - badge: '💬', - }; - } - } - - return undefined; - } -} - -export const DecorationProvider = new TreeDecorationProvider(); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { fromFileChangeNodeUri } from '../common/uri'; + +class TreeDecorationProvider implements vscode.FileDecorationProvider { + private fileHasComments: Map = new Map(); + + updateFileComments(resourceUri: vscode.Uri, prNumber: number, fileName: string, hasComments: boolean): void { + const key = `${prNumber}:${fileName}`; + const oldValue = this.fileHasComments.get(key); + if (oldValue !== hasComments) { + this.fileHasComments.set(`${prNumber}:${fileName}`, hasComments); + this._onDidChangeFileDecorations.fire(resourceUri); + } + } + + _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + const query = fromFileChangeNodeUri(uri); + if (query) { + const key = `${query.prNumber}:${query.fileName}`; + if (this.fileHasComments.get(key)) { + return { + propagate: false, + tooltip: 'Commented', + badge: '💬', + }; + } + } + + return undefined; + } +} + +export const DecorationProvider = new TreeDecorationProvider(); diff --git a/src/view/treeNodes/categoryNode.ts b/src/view/treeNodes/categoryNode.ts index fc585e1a56..d8e2b3a19e 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -1,379 +1,380 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { AuthenticationError } from '../../common/authentication'; -import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; -import { ITelemetry } from '../../common/telemetry'; -import { formatError } from '../../common/utils'; -import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; -import { PRType } from '../../github/interface'; -import { NotificationProvider } from '../../github/notifications'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { PrsTreeModel } from '../prsTreeModel'; -import { PRNode } from './pullRequestNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export enum PRCategoryActionType { - Empty, - More, - TryOtherRemotes, - Login, - LoginEnterprise, - NoRemotes, - NoMatchingRemotes, - ConfigureRemotes, -} - -interface QueryInspect { - key: string; - defaultValue?: { label: string; query: string }[]; - globalValue?: { label: string; query: string }[]; - workspaceValue?: { label: string; query: string }[]; - workspaceFolderValue?: { label: string; query: string }[]; - defaultLanguageValue?: { label: string; query: string }[]; - globalLanguageValue?: { label: string; query: string }[]; - workspaceLanguageValue?: { label: string; query: string }[]; - workspaceFolderLanguageValue?: { label: string; query: string }[]; - languageIds?: string[] -} - -export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { - public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; - public type: PRCategoryActionType; - public command?: vscode.Command; - - constructor(parent: TreeNodeParent, type: PRCategoryActionType, node?: CategoryTreeNode) { - super(); - this.parent = parent; - this.type = type; - this.collapsibleState = vscode.TreeItemCollapsibleState.None; - switch (type) { - case PRCategoryActionType.Empty: - this.label = vscode.l10n.t('0 pull requests in this category'); - break; - case PRCategoryActionType.More: - this.label = vscode.l10n.t('Load more'); - this.command = { - title: vscode.l10n.t('Load more'), - command: 'pr.loadMore', - arguments: [node], - }; - break; - case PRCategoryActionType.TryOtherRemotes: - this.label = vscode.l10n.t('Continue fetching from other remotes'); - this.command = { - title: vscode.l10n.t('Load more'), - command: 'pr.loadMore', - arguments: [node], - }; - break; - case PRCategoryActionType.Login: - this.label = vscode.l10n.t('Sign in'); - this.command = { - title: vscode.l10n.t('Sign in'), - command: 'pr.signinAndRefreshList', - arguments: [], - }; - break; - case PRCategoryActionType.LoginEnterprise: - this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); - this.command = { - title: 'Sign in', - command: 'pr.signinAndRefreshList', - arguments: [], - }; - break; - case PRCategoryActionType.NoRemotes: - this.label = vscode.l10n.t('No GitHub repositories found.'); - break; - case PRCategoryActionType.NoMatchingRemotes: - this.label = vscode.l10n.t('No remotes match the current setting.'); - break; - case PRCategoryActionType.ConfigureRemotes: - this.label = vscode.l10n.t('Configure remotes...'); - this.command = { - title: vscode.l10n.t('Configure remotes'), - command: 'pr.configureRemotes', - arguments: [], - }; - break; - default: - break; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} - -interface PageInformation { - pullRequestPage: number; - hasMorePages: boolean; -} - -export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { - protected children: (PRNode | PRCategoryActionNode)[] | undefined = undefined; - public collapsibleState: vscode.TreeItemCollapsibleState; - public prs: PullRequestModel[]; - public fetchNextPage: boolean = false; - public repositoryPageInformation: Map = new Map(); - public contextValue: string; - public readonly id: string = ''; - private _firstLoad: boolean = true; - - constructor( - public parent: TreeNodeParent, - private _folderRepoManager: FolderRepositoryManager, - private _telemetry: ITelemetry, - public readonly type: PRType, - private _notificationProvider: NotificationProvider, - expandedQueries: Set, - private _prsTreeModel: PrsTreeModel, - _categoryLabel?: string, - private _categoryQuery?: string, - ) { - super(); - - this.prs = []; - - switch (this.type) { - case PRType.All: - this.label = vscode.l10n.t('All Open'); - break; - case PRType.Query: - this.label = _categoryLabel!; - break; - case PRType.LocalPullRequest: - this.label = vscode.l10n.t('Local Pull Request Branches'); - break; - default: - this.label = ''; - break; - } - - this.id = parent instanceof TreeNode ? `${parent.id ?? parent.label}/${this.label}` : this.label; - - if ((expandedQueries.size === 0) && (this.type === PRType.All)) { - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - } else { - this.collapsibleState = - expandedQueries.has(this.id) - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed; - } - - if (this._categoryQuery) { - this.contextValue = 'query'; - } - } - - private async addNewQuery(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined, startingValue: string) { - const inputBox = vscode.window.createInputBox(); - inputBox.title = vscode.l10n.t('Enter the title of the new query'); - inputBox.placeholder = vscode.l10n.t('Title'); - inputBox.step = 1; - inputBox.totalSteps = 2; - inputBox.show(); - let title: string | undefined; - inputBox.onDidAccept(async () => { - inputBox.validationMessage = ''; - if (inputBox.step === 1) { - if (!inputBox.value) { - inputBox.validationMessage = vscode.l10n.t('Title is required'); - return; - } - - title = inputBox.value; - inputBox.value = startingValue; - inputBox.title = vscode.l10n.t('Enter the GitHub search query'); - inputBox.step++; - } else { - if (!inputBox.value) { - inputBox.validationMessage = vscode.l10n.t('Query is required'); - return; - } - inputBox.busy = true; - if (inputBox.value && title) { - if (inspect?.workspaceValue) { - inspect.workspaceValue.push({ label: title, query: inputBox.value }); - await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES); - value?.push({ label: title, query: inputBox.value }); - await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); - } - } - inputBox.dispose(); - } - }); - inputBox.onDidHide(() => inputBox.dispose()); - } - - private updateQuery(queries: { label: string; query: string }[], queryToUpdate: { label: string; query: string }) { - for (const query of queries) { - if (query.label === queryToUpdate.label) { - query.query = queryToUpdate.query; - return; - } - } - } - - private async openSettings(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined) { - let command: string; - if (inspect?.workspaceValue) { - command = 'workbench.action.openWorkspaceSettingsFile'; - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES); - if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - await config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); - } - command = 'workbench.action.openSettingsJson'; - } - await vscode.commands.executeCommand(command); - const editor = vscode.window.activeTextEditor; - if (editor) { - const text = editor.document.getText(); - const search = text.search(this.label!); - if (search >= 0) { - const position = editor.document.positionAt(search); - editor.revealRange(new vscode.Range(position, position)); - editor.selection = new vscode.Selection(position, position); - } - } - } - - public async expandPullRequest(pullRequest: PullRequestModel, retry: boolean = true): Promise { - if (!this.children && retry) { - await this.getChildren(); - retry = false; - } - if (this.children) { - for (const child of this.children) { - if (child instanceof PRNode) { - if (child.pullRequestModel.equals(pullRequest)) { - this.reveal(child, { expand: true, select: true }); - return true; - } - } - } - // If we didn't find the PR, we might need to re-run the query - if (retry) { - await this.getChildren(); - return await this.expandPullRequest(pullRequest, false); - } - } - return false; - } - - async editQuery() { - const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); - - const inputBox = vscode.window.createQuickPick(); - inputBox.title = vscode.l10n.t('Edit Pull Request Query "{0}"', this.label ?? ''); - inputBox.value = this._categoryQuery ?? ''; - inputBox.items = [{ iconPath: new vscode.ThemeIcon('pencil'), label: vscode.l10n.t('Save edits'), alwaysShow: true }, { iconPath: new vscode.ThemeIcon('add'), label: vscode.l10n.t('Add new query'), alwaysShow: true }, { iconPath: new vscode.ThemeIcon('settings'), label: vscode.l10n.t('Edit in settings.json'), alwaysShow: true }]; - inputBox.activeItems = []; - inputBox.selectedItems = []; - inputBox.onDidAccept(async () => { - inputBox.busy = true; - if (inputBox.selectedItems[0] === inputBox.items[0]) { - const newQuery = inputBox.value; - if (newQuery !== this._categoryQuery && this.label) { - if (inspect?.workspaceValue) { - this.updateQuery(inspect.workspaceValue, { label: this.label, query: newQuery }); - await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES) ?? inspect!.defaultValue!; - this.updateQuery(value, { label: this.label, query: newQuery }); - await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); - } - } - } else if (inputBox.selectedItems[0] === inputBox.items[1]) { - this.addNewQuery(config, inspect, inputBox.value); - } else if (inputBox.selectedItems[0] === inputBox.items[2]) { - this.openSettings(config, inspect); - } - inputBox.dispose(); - }); - inputBox.onDidHide(() => inputBox.dispose()); - inputBox.show(); - } - - async getChildren(): Promise { - await super.getChildren(); - if (this._firstLoad) { - this._firstLoad = false; - this.doGetChildren().then(() => this.refresh(this)); - return []; - } - return this.doGetChildren(); - } - - private async doGetChildren(): Promise { - let hasMorePages = false; - let hasUnsearchedRepositories = false; - let needLogin = false; - if (this.type === PRType.LocalPullRequest) { - try { - this.prs = (await this._prsTreeModel.getLocalPullRequests(this._folderRepoManager)).items; - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching local pull requests failed: {0}', formatError(e))); - needLogin = e instanceof AuthenticationError; - } - } else { - try { - let response: ItemsResponseResult; - switch (this.type) { - case PRType.All: - response = await this._prsTreeModel.getAllPullRequests(this._folderRepoManager, this.fetchNextPage); - break; - case PRType.Query: - response = await this._prsTreeModel.getPullRequestsForQuery(this._folderRepoManager, this.fetchNextPage, this._categoryQuery!); - break; - } - if (!this.fetchNextPage) { - this.prs = response.items; - } else { - this.prs = this.prs.concat(response.items); - } - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching pull requests failed: {0}', formatError(e))); - needLogin = e instanceof AuthenticationError; - } finally { - this.fetchNextPage = false; - } - } - - if (this.prs && this.prs.length) { - const nodes: (PRNode | PRCategoryActionNode)[] = this.prs.map( - prItem => new PRNode(this, this._folderRepoManager, prItem, this.type === PRType.LocalPullRequest, this._notificationProvider), - ); - if (hasMorePages) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); - } else if (hasUnsearchedRepositories) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); - } - - this.children = nodes; - return nodes; - } else { - const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; - const result = [new PRCategoryActionNode(this, category)]; - - this.children = result; - return result; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { AuthenticationError } from '../../common/authentication'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; +import { ITelemetry } from '../../common/telemetry'; +import { formatError } from '../../common/utils'; +import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; +import { PRType } from '../../github/interface'; +import { NotificationProvider } from '../../github/notifications'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { PrsTreeModel } from '../prsTreeModel'; +import { PRNode } from './pullRequestNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export enum PRCategoryActionType { + Empty, + More, + TryOtherRemotes, + Login, + LoginEnterprise, + NoRemotes, + NoMatchingRemotes, + ConfigureRemotes, +} + +interface QueryInspect { + key: string; + defaultValue?: { label: string; query: string }[]; + globalValue?: { label: string; query: string }[]; + workspaceValue?: { label: string; query: string }[]; + workspaceFolderValue?: { label: string; query: string }[]; + defaultLanguageValue?: { label: string; query: string }[]; + globalLanguageValue?: { label: string; query: string }[]; + workspaceLanguageValue?: { label: string; query: string }[]; + workspaceFolderLanguageValue?: { label: string; query: string }[]; + languageIds?: string[] +} + +export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { + public collapsibleState: vscode.TreeItemCollapsibleState; + public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; + public type: PRCategoryActionType; + public command?: vscode.Command; + + constructor(parent: TreeNodeParent, type: PRCategoryActionType, node?: CategoryTreeNode) { + super(); + this.parent = parent; + this.type = type; + this.collapsibleState = vscode.TreeItemCollapsibleState.None; + switch (type) { + case PRCategoryActionType.Empty: + this.label = vscode.l10n.t('0 pull requests in this category'); + break; + case PRCategoryActionType.More: + this.label = vscode.l10n.t('Load more'); + this.command = { + title: vscode.l10n.t('Load more'), + command: 'pr.loadMore', + arguments: [node], + }; + break; + case PRCategoryActionType.TryOtherRemotes: + this.label = vscode.l10n.t('Continue fetching from other remotes'); + this.command = { + title: vscode.l10n.t('Load more'), + command: 'pr.loadMore', + arguments: [node], + }; + break; + case PRCategoryActionType.Login: + this.label = vscode.l10n.t('Sign in'); + this.command = { + title: vscode.l10n.t('Sign in'), + command: 'pr.signinAndRefreshList', + arguments: [], + }; + break; + case PRCategoryActionType.LoginEnterprise: + this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); + this.command = { + title: 'Sign in', + command: 'pr.signinAndRefreshList', + arguments: [], + }; + break; + case PRCategoryActionType.NoRemotes: + this.label = vscode.l10n.t('No GitHub repositories found.'); + break; + case PRCategoryActionType.NoMatchingRemotes: + this.label = vscode.l10n.t('No remotes match the current setting.'); + break; + case PRCategoryActionType.ConfigureRemotes: + this.label = vscode.l10n.t('Configure remotes...'); + this.command = { + title: vscode.l10n.t('Configure remotes'), + command: 'pr.configureRemotes', + arguments: [], + }; + break; + default: + break; + } + } + + getTreeItem(): vscode.TreeItem { + return this; + } +} + +interface PageInformation { + pullRequestPage: number; + hasMorePages: boolean; +} + +export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { + protected children: (PRNode | PRCategoryActionNode)[] | undefined = undefined; + public collapsibleState: vscode.TreeItemCollapsibleState; + public prs: PullRequestModel[]; + public fetchNextPage: boolean = false; + public repositoryPageInformation: Map = new Map(); + public contextValue: string; + public readonly id: string = ''; + private _firstLoad: boolean = true; + + constructor( + public parent: TreeNodeParent, + private _folderRepoManager: FolderRepositoryManager, + private _telemetry: ITelemetry, + public readonly type: PRType, + private _notificationProvider: NotificationProvider, + expandedQueries: Set, + private _prsTreeModel: PrsTreeModel, + _categoryLabel?: string, + private _categoryQuery?: string, + ) { + super(); + + this.prs = []; + + switch (this.type) { + case PRType.All: + this.label = vscode.l10n.t('All Open'); + break; + case PRType.Query: + this.label = _categoryLabel!; + break; + case PRType.LocalPullRequest: + this.label = vscode.l10n.t('Local Pull Request Branches'); + break; + default: + this.label = ''; + break; + } + + this.id = parent instanceof TreeNode ? `${parent.id ?? parent.label}/${this.label}` : this.label; + + if ((expandedQueries.size === 0) && (this.type === PRType.All)) { + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + } else { + this.collapsibleState = + expandedQueries.has(this.id) + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + } + + if (this._categoryQuery) { + this.contextValue = 'query'; + } + } + + private async addNewQuery(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined, startingValue: string) { + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Enter the title of the new query'); + inputBox.placeholder = vscode.l10n.t('Title'); + inputBox.step = 1; + inputBox.totalSteps = 2; + inputBox.show(); + let title: string | undefined; + inputBox.onDidAccept(async () => { + inputBox.validationMessage = ''; + if (inputBox.step === 1) { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Title is required'); + return; + } + + title = inputBox.value; + inputBox.value = startingValue; + inputBox.title = vscode.l10n.t('Enter the GitHub search query'); + inputBox.step++; + } else { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Query is required'); + return; + } + inputBox.busy = true; + if (inputBox.value && title) { + if (inspect?.workspaceValue) { + inspect.workspaceValue.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + value?.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); + } + } + inputBox.dispose(); + } + }); + inputBox.onDidHide(() => inputBox.dispose()); + } + + private updateQuery(queries: { label: string; query: string }[], queryToUpdate: { label: string; query: string }) { + for (const query of queries) { + if (query.label === queryToUpdate.label) { + query.query = queryToUpdate.query; + return; + } + } + } + + private async openSettings(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined) { + let command: string; + if (inspect?.workspaceValue) { + command = 'workbench.action.openWorkspaceSettingsFile'; + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { + await config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); + } + command = 'workbench.action.openSettingsJson'; + } + await vscode.commands.executeCommand(command); + const editor = vscode.window.activeTextEditor; + if (editor) { + const text = editor.document.getText(); + const search = text.search(this.label!); + if (search >= 0) { + const position = editor.document.positionAt(search); + editor.revealRange(new vscode.Range(position, position)); + editor.selection = new vscode.Selection(position, position); + } + } + } + + public async expandPullRequest(pullRequest: PullRequestModel, retry: boolean = true): Promise { + if (!this.children && retry) { + await this.getChildren(); + retry = false; + } + if (this.children) { + for (const child of this.children) { + if (child instanceof PRNode) { + if (child.pullRequestModel.equals(pullRequest)) { + this.reveal(child, { expand: true, select: true }); + return true; + } + } + } + // If we didn't find the PR, we might need to re-run the query + if (retry) { + await this.getChildren(); + return await this.expandPullRequest(pullRequest, false); + } + } + return false; + } + + async editQuery() { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); + + const inputBox = vscode.window.createQuickPick(); + inputBox.title = vscode.l10n.t('Edit Pull Request Query "{0}"', this.label ?? ''); + inputBox.value = this._categoryQuery ?? ''; + inputBox.items = [{ iconPath: new vscode.ThemeIcon('pencil'), label: vscode.l10n.t('Save edits'), alwaysShow: true }, { iconPath: new vscode.ThemeIcon('add'), label: vscode.l10n.t('Add new query'), alwaysShow: true }, { iconPath: new vscode.ThemeIcon('settings'), label: vscode.l10n.t('Edit in settings.json'), alwaysShow: true }]; + inputBox.activeItems = []; + inputBox.selectedItems = []; + inputBox.onDidAccept(async () => { + inputBox.busy = true; + if (inputBox.selectedItems[0] === inputBox.items[0]) { + const newQuery = inputBox.value; + if (newQuery !== this._categoryQuery && this.label) { + if (inspect?.workspaceValue) { + this.updateQuery(inspect.workspaceValue, { label: this.label, query: newQuery }); + await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES) ?? inspect!.defaultValue!; + this.updateQuery(value, { label: this.label, query: newQuery }); + await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); + } + } + } else if (inputBox.selectedItems[0] === inputBox.items[1]) { + this.addNewQuery(config, inspect, inputBox.value); + } else if (inputBox.selectedItems[0] === inputBox.items[2]) { + this.openSettings(config, inspect); + } + inputBox.dispose(); + }); + inputBox.onDidHide(() => inputBox.dispose()); + inputBox.show(); + } + + async getChildren(): Promise { + await super.getChildren(); + if (this._firstLoad) { + this._firstLoad = false; + this.doGetChildren().then(() => this.refresh(this)); + return []; + } + return this.doGetChildren(); + } + + private async doGetChildren(): Promise { + let hasMorePages = false; + let hasUnsearchedRepositories = false; + let needLogin = false; + if (this.type === PRType.LocalPullRequest) { + try { + this.prs = (await this._prsTreeModel.getLocalPullRequests(this._folderRepoManager)).items; + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching local pull requests failed: {0}', formatError(e))); + needLogin = e instanceof AuthenticationError; + } + } else { + try { + let response: ItemsResponseResult; + switch (this.type) { + case PRType.All: + response = await this._prsTreeModel.getAllPullRequests(this._folderRepoManager, this.fetchNextPage); + break; + case PRType.Query: + response = await this._prsTreeModel.getPullRequestsForQuery(this._folderRepoManager, this.fetchNextPage, this._categoryQuery!); + break; + } + if (!this.fetchNextPage) { + this.prs = response.items; + } else { + this.prs = this.prs.concat(response.items); + } + hasMorePages = response.hasMorePages; + hasUnsearchedRepositories = response.hasUnsearchedRepositories; + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching pull requests failed: {0}', formatError(e))); + needLogin = e instanceof AuthenticationError; + } finally { + this.fetchNextPage = false; + } + } + + if (this.prs && this.prs.length) { + const nodes: (PRNode | PRCategoryActionNode)[] = this.prs.map( + prItem => new PRNode(this, this._folderRepoManager, prItem, this.type === PRType.LocalPullRequest, this._notificationProvider), + ); + if (hasMorePages) { + nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); + } else if (hasUnsearchedRepositories) { + nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); + } + + this.children = nodes; + return nodes; + } else { + const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; + const result = [new PRCategoryActionNode(this, category)]; + + this.children = result; + return result; + } + } + + getTreeItem(): vscode.TreeItem { + return this; + } +} diff --git a/src/view/treeNodes/commitNode.ts b/src/view/treeNodes/commitNode.ts index cf4d106f6b..0011575e8b 100644 --- a/src/view/treeNodes/commitNode.ts +++ b/src/view/treeNodes/commitNode.ts @@ -1,119 +1,120 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { getGitChangeType } from '../../common/diffHunk'; -import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; -import { DataUri, toReviewUri } from '../../common/uri'; -import { OctokitCommon } from '../../github/common'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { IAccount } from '../../github/interface'; -import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; -import { GitFileChangeModel } from '../fileChangeModel'; -import { DirectoryTreeNode } from './directoryTreeNode'; -import { GitFileChangeNode } from './fileChangeNode'; -import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; - -export class CommitNode extends TreeNode implements vscode.TreeItem { - public sha: string; - public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath: vscode.Uri | undefined; - public contextValue?: string; - - constructor( - public parent: TreeNodeParent, - private readonly pullRequestManager: FolderRepositoryManager, - private readonly pullRequest: PullRequestModel, - private readonly commit: OctokitCommon.PullsListCommitsResponseItem, - private readonly isCurrent: boolean - ) { - super(); - this.label = commit.commit.message; - this.sha = commit.sha; - this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - this.contextValue = 'commit'; - } - - async getTreeItem(): Promise { - if (this.commit.author) { - const author: IAccount = { id: this.commit.author.node_id, login: this.commit.author.login, url: this.commit.author.url, avatarUrl: this.commit.author.avatar_url }; - this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.pullRequestManager.context, [author], 16, 16))[0]; - } - return this; - } - - async getChildren(): Promise { - super.getChildren(); - const fileChanges = (await this.pullRequest.getCommitChangedFiles(this.commit)) ?? []; - - if (fileChanges.length === 0) { - return [new LabelOnlyNode('No changed files')]; - } - - const fileChangeNodes = fileChanges.map(change => { - const fileName = change.filename!; - const uri = vscode.Uri.parse(path.posix.join(`commit~${this.commit.sha.substr(0, 8)}`, fileName)); - const changeModel = new GitFileChangeModel( - this.pullRequestManager, - this.pullRequest, - { - status: getGitChangeType(change.status!), - fileName, - blobUrl: undefined - }, - toReviewUri( - uri, - fileName, - undefined, - this.commit.sha, - true, - { base: false }, - this.pullRequestManager.repository.rootUri, - ), - toReviewUri( - uri, - fileName, - undefined, - this.commit.sha, - true, - { base: true }, - this.pullRequestManager.repository.rootUri, - ), - this.commit.sha); - const fileChangeNode = new GitFileChangeNode( - this, - this.pullRequestManager, - this.pullRequest as (PullRequestModel & IResolvedPullRequestModel), - changeModel, - this.isCurrent - ); - - fileChangeNode.useViewChangesCommand(); - - return fileChangeNode; - }); - - let result: TreeNode[] = []; - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - if (layout === 'tree') { - // tree view - const dirNode = new DirectoryTreeNode(this, ''); - fileChangeNodes.forEach(f => dirNode.addFile(f)); - dirNode.finalize(); - if (dirNode.label === '') { - // nothing on the root changed, pull children to parent - result.push(...dirNode.children); - } else { - result.push(dirNode); - } - } else { - // flat view - result = fileChangeNodes; - } - this.children = result; - return result; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getGitChangeType } from '../../common/diffHunk'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { DataUri, toReviewUri } from '../../common/uri'; +import { OctokitCommon } from '../../github/common'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { IAccount } from '../../github/interface'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { GitFileChangeModel } from '../fileChangeModel'; +import { DirectoryTreeNode } from './directoryTreeNode'; +import { GitFileChangeNode } from './fileChangeNode'; +import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; + +export class CommitNode extends TreeNode implements vscode.TreeItem { + public sha: string; + public collapsibleState: vscode.TreeItemCollapsibleState; + public iconPath: vscode.Uri | undefined; + public contextValue?: string; + + constructor( + public parent: TreeNodeParent, + private readonly pullRequestManager: FolderRepositoryManager, + private readonly pullRequest: PullRequestModel, + private readonly commit: OctokitCommon.PullsListCommitsResponseItem, + private readonly isCurrent: boolean + ) { + super(); + this.label = commit.commit.message; + this.sha = commit.sha; + this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + this.contextValue = 'commit'; + } + + async getTreeItem(): Promise { + if (this.commit.author) { + const author: IAccount = { id: this.commit.author.node_id, login: this.commit.author.login, url: this.commit.author.url, avatarUrl: this.commit.author.avatar_url }; + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.pullRequestManager.context, [author], 16, 16))[0]; + } + return this; + } + + async getChildren(): Promise { + super.getChildren(); + const fileChanges = (await this.pullRequest.getCommitChangedFiles(this.commit)) ?? []; + + if (fileChanges.length === 0) { + return [new LabelOnlyNode('No changed files')]; + } + + const fileChangeNodes = fileChanges.map(change => { + const fileName = change.filename!; + const uri = vscode.Uri.parse(path.posix.join(`commit~${this.commit.sha.substr(0, 8)}`, fileName)); + const changeModel = new GitFileChangeModel( + this.pullRequestManager, + this.pullRequest, + { + status: getGitChangeType(change.status!), + fileName, + blobUrl: undefined + }, + toReviewUri( + uri, + fileName, + undefined, + this.commit.sha, + true, + { base: false }, + this.pullRequestManager.repository.rootUri, + ), + toReviewUri( + uri, + fileName, + undefined, + this.commit.sha, + true, + { base: true }, + this.pullRequestManager.repository.rootUri, + ), + this.commit.sha); + const fileChangeNode = new GitFileChangeNode( + this, + this.pullRequestManager, + this.pullRequest as (PullRequestModel & IResolvedPullRequestModel), + changeModel, + this.isCurrent + ); + + fileChangeNode.useViewChangesCommand(); + + return fileChangeNode; + }); + + let result: TreeNode[] = []; + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + if (layout === 'tree') { + // tree view + const dirNode = new DirectoryTreeNode(this, ''); + fileChangeNodes.forEach(f => dirNode.addFile(f)); + dirNode.finalize(); + if (dirNode.label === '') { + // nothing on the root changed, pull children to parent + result.push(...dirNode.children); + } else { + result.push(dirNode); + } + } else { + // flat view + result = fileChangeNodes; + } + this.children = result; + return result; + } +} diff --git a/src/view/treeNodes/commitsCategoryNode.ts b/src/view/treeNodes/commitsCategoryNode.ts index eec88ccdde..9731f111f9 100644 --- a/src/view/treeNodes/commitsCategoryNode.ts +++ b/src/view/treeNodes/commitsCategoryNode.ts @@ -1,59 +1,60 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import Logger, { PR_TREE } from '../../common/logger'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { CommitNode } from './commitNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export class CommitsNode extends TreeNode implements vscode.TreeItem { - public label: string = vscode.l10n.t('Commits'); - public collapsibleState: vscode.TreeItemCollapsibleState; - private _folderRepoManager: FolderRepositoryManager; - private _pr: PullRequestModel; - - constructor( - parent: TreeNodeParent, - reposManager: FolderRepositoryManager, - pr: PullRequestModel, - ) { - super(); - this.parent = parent; - this._pr = pr; - this._folderRepoManager = reposManager; - this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - - this.childrenDisposables = []; - this.childrenDisposables.push(this._pr.onDidChangeReviewThreads(() => { - Logger.appendLine(`Review threads have changed, refreshing Commits node`, PR_TREE); - this.refresh(this); - })); - this.childrenDisposables.push(this._pr.onDidChangeComments(() => { - Logger.appendLine(`Comments have changed, refreshing Commits node`, PR_TREE); - this.refresh(this); - })); - } - - getTreeItem(): vscode.TreeItem { - return this; - } - - async getChildren(): Promise { - super.getChildren(); - try { - Logger.appendLine(`Getting children for Commits node`, PR_TREE); - const commits = await this._pr.getCommits(); - this.children = commits.map( - (commit, index) => new CommitNode(this, this._folderRepoManager, this._pr, commit, (index === commits.length - 1) && (this._folderRepoManager.repository.state.HEAD?.commit === commit.sha)), - ); - Logger.appendLine(`Got all children for Commits node`, PR_TREE); - return this.children; - } catch (e) { - return []; - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import Logger, { PR_TREE } from '../../common/logger'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { CommitNode } from './commitNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export class CommitsNode extends TreeNode implements vscode.TreeItem { + public label: string = vscode.l10n.t('Commits'); + public collapsibleState: vscode.TreeItemCollapsibleState; + private _folderRepoManager: FolderRepositoryManager; + private _pr: PullRequestModel; + + constructor( + parent: TreeNodeParent, + reposManager: FolderRepositoryManager, + pr: PullRequestModel, + ) { + super(); + this.parent = parent; + this._pr = pr; + this._folderRepoManager = reposManager; + this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + + this.childrenDisposables = []; + this.childrenDisposables.push(this._pr.onDidChangeReviewThreads(() => { + Logger.appendLine(`Review threads have changed, refreshing Commits node`, PR_TREE); + this.refresh(this); + })); + this.childrenDisposables.push(this._pr.onDidChangeComments(() => { + Logger.appendLine(`Comments have changed, refreshing Commits node`, PR_TREE); + this.refresh(this); + })); + } + + getTreeItem(): vscode.TreeItem { + return this; + } + + async getChildren(): Promise { + super.getChildren(); + try { + Logger.appendLine(`Getting children for Commits node`, PR_TREE); + const commits = await this._pr.getCommits(); + this.children = commits.map( + (commit, index) => new CommitNode(this, this._folderRepoManager, this._pr, commit, (index === commits.length - 1) && (this._folderRepoManager.repository.state.HEAD?.commit === commit.sha)), + ); + Logger.appendLine(`Got all children for Commits node`, PR_TREE); + return this.children; + } catch (e) { + return []; + } + } +} diff --git a/src/view/treeNodes/descriptionNode.ts b/src/view/treeNodes/descriptionNode.ts index 736a6349fe..86d21a5508 100644 --- a/src/view/treeNodes/descriptionNode.ts +++ b/src/view/treeNodes/descriptionNode.ts @@ -1,49 +1,50 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Repository } from '../../api/api'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export class DescriptionNode extends TreeNode implements vscode.TreeItem { - public command?: vscode.Command; - public contextValue?: string; - public tooltip: string; - public iconPath: vscode.ThemeIcon | vscode.Uri | undefined; - - constructor( - public parent: TreeNodeParent, - public label: string, - public pullRequestModel: PullRequestModel, - public readonly repository: Repository, - private readonly folderRepositoryManager: FolderRepositoryManager - ) { - super(); - - this.command = { - title: vscode.l10n.t('View Pull Request Description'), - command: 'pr.openDescription', - arguments: [this], - }; - this.iconPath = new vscode.ThemeIcon('git-pull-request'); - this.tooltip = vscode.l10n.t('Description of pull request #{0}', pullRequestModel.number); - this.accessibilityInformation = { label: vscode.l10n.t('Pull request page of pull request number {0}', pullRequestModel.number), role: 'button' }; - } - - async getTreeItem(): Promise { - this.updateContextValue(); - return this; - } - - protected updateContextValue(): void { - const currentBranchIsForThisPR = this.pullRequestModel.equals(this.folderRepositoryManager.activePullRequest); - this.contextValue = 'description' + - (currentBranchIsForThisPR ? ':active' : ':nonactive') + - (this.pullRequestModel.hasChangesSinceLastReview ? ':hasChangesSinceReview' : '') + - (this.pullRequestModel.showChangesSinceReview ? ':showingChangesSinceReview' : ':showingAllChanges'); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Repository } from '../../api/api'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export class DescriptionNode extends TreeNode implements vscode.TreeItem { + public command?: vscode.Command; + public contextValue?: string; + public tooltip: string; + public iconPath: vscode.ThemeIcon | vscode.Uri | undefined; + + constructor( + public parent: TreeNodeParent, + public label: string, + public pullRequestModel: PullRequestModel, + public readonly repository: Repository, + private readonly folderRepositoryManager: FolderRepositoryManager + ) { + super(); + + this.command = { + title: vscode.l10n.t('View Pull Request Description'), + command: 'pr.openDescription', + arguments: [this], + }; + this.iconPath = new vscode.ThemeIcon('git-pull-request'); + this.tooltip = vscode.l10n.t('Description of pull request #{0}', pullRequestModel.number); + this.accessibilityInformation = { label: vscode.l10n.t('Pull request page of pull request number {0}', pullRequestModel.number), role: 'button' }; + } + + async getTreeItem(): Promise { + this.updateContextValue(); + return this; + } + + protected updateContextValue(): void { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this.folderRepositoryManager.activePullRequest); + this.contextValue = 'description' + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (this.pullRequestModel.hasChangesSinceLastReview ? ':hasChangesSinceReview' : '') + + (this.pullRequestModel.showChangesSinceReview ? ':showingChangesSinceReview' : ':showingAllChanges'); + } +} diff --git a/src/view/treeNodes/directoryTreeNode.ts b/src/view/treeNodes/directoryTreeNode.ts index d0134cabc0..ac1cf458e5 100644 --- a/src/view/treeNodes/directoryTreeNode.ts +++ b/src/view/treeNodes/directoryTreeNode.ts @@ -1,143 +1,144 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { GitFileChangeNode, InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { - public collapsibleState: vscode.TreeItemCollapsibleState; - public children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; - private pathToChild: Map = new Map(); - public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string, accessibilityInformation: vscode.AccessibilityInformation }; - - constructor(public parent: TreeNodeParent, public label: string) { - super(); - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - } - - async getChildren(): Promise { - return this.children; - } - - public finalize(): void { - this.trimTree(); - this.sort(); - } - - private trimTree(): void { - if (this.children.length === 0) { - return; - } - - this.children.forEach(n => { - if (n instanceof DirectoryTreeNode) { - n.trimTree(); // recursive - } - }); - - // merge if this only have single directory, eg: - // - a - // - b - // - c - // becomes: - // - a/b - // - c - if (this.children.length !== 1) { - return; - } - const child = this.children[0]; - if (!(child instanceof DirectoryTreeNode)) { - return; - } - - // perform the merge - this.label = this.label + '/' + child.label; - if (this.label.startsWith('/')) { - this.label = this.label.substr(1); - } - this.children = child.children; - this.children.forEach(child => { child.parent = this; }); - } - - private sort(): void { - if (this.children.length <= 1) { - return; - } - - const dirs: DirectoryTreeNode[] = []; - const files: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode)[] = []; - - // process directory - this.children.forEach(node => { - if (node instanceof DirectoryTreeNode) { - node.sort(); // recc - dirs.push(node); - } else { - // files - files.push(node); - } - }); - - // sort - dirs.sort((a, b) => (a.label < b.label ? -1 : 1)); - files.sort((a, b) => (a.label! < b.label! ? -1 : 1)); - - this.children = [...dirs, ...files]; - } - - public addFile(file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { - const paths = file.changeModel.fileName.split('/'); - this.addPathRecc(paths, file); - } - - private addPathRecc(paths: string[], file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { - if (paths.length <= 0) { - return; - } - - if (paths.length === 1) { - file.parent = this; - this.children.push(file); - return; - } - - const dir = paths[0]; // top directory - const tail = paths.slice(1); // rest - - let node = this.pathToChild.get(dir); - if (!node) { - node = new DirectoryTreeNode(this, dir); - this.pathToChild.set(dir, node); - this.children.push(node); - } - - node.addPathRecc(tail, file); - } - - public allChildrenViewed(): boolean { - for (const child of this.children) { - if (child instanceof DirectoryTreeNode) { - if (!child.allChildrenViewed()) { - return false; - } - } else if (child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { - return false; - } - } - return true; - } - - private setCheckboxState(isChecked: boolean) { - this.checkboxState = isChecked ? - { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark all files unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as unviewed', this.label) } } : - { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark all files viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as viewed', this.label) } }; - } - - getTreeItem(): vscode.TreeItem { - this.setCheckboxState(this.allChildrenViewed()); - return this; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { GitFileChangeNode, InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { + public collapsibleState: vscode.TreeItemCollapsibleState; + public children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; + private pathToChild: Map = new Map(); + public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string, accessibilityInformation: vscode.AccessibilityInformation }; + + constructor(public parent: TreeNodeParent, public label: string) { + super(); + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + } + + async getChildren(): Promise { + return this.children; + } + + public finalize(): void { + this.trimTree(); + this.sort(); + } + + private trimTree(): void { + if (this.children.length === 0) { + return; + } + + this.children.forEach(n => { + if (n instanceof DirectoryTreeNode) { + n.trimTree(); // recursive + } + }); + + // merge if this only have single directory, eg: + // - a + // - b + // - c + // becomes: + // - a/b + // - c + if (this.children.length !== 1) { + return; + } + const child = this.children[0]; + if (!(child instanceof DirectoryTreeNode)) { + return; + } + + // perform the merge + this.label = this.label + '/' + child.label; + if (this.label.startsWith('/')) { + this.label = this.label.substr(1); + } + this.children = child.children; + this.children.forEach(child => { child.parent = this; }); + } + + private sort(): void { + if (this.children.length <= 1) { + return; + } + + const dirs: DirectoryTreeNode[] = []; + const files: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode)[] = []; + + // process directory + this.children.forEach(node => { + if (node instanceof DirectoryTreeNode) { + node.sort(); // recc + dirs.push(node); + } else { + // files + files.push(node); + } + }); + + // sort + dirs.sort((a, b) => (a.label < b.label ? -1 : 1)); + files.sort((a, b) => (a.label! < b.label! ? -1 : 1)); + + this.children = [...dirs, ...files]; + } + + public addFile(file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { + const paths = file.changeModel.fileName.split('/'); + this.addPathRecc(paths, file); + } + + private addPathRecc(paths: string[], file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { + if (paths.length <= 0) { + return; + } + + if (paths.length === 1) { + file.parent = this; + this.children.push(file); + return; + } + + const dir = paths[0]; // top directory + const tail = paths.slice(1); // rest + + let node = this.pathToChild.get(dir); + if (!node) { + node = new DirectoryTreeNode(this, dir); + this.pathToChild.set(dir, node); + this.children.push(node); + } + + node.addPathRecc(tail, file); + } + + public allChildrenViewed(): boolean { + for (const child of this.children) { + if (child instanceof DirectoryTreeNode) { + if (!child.allChildrenViewed()) { + return false; + } + } else if (child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { + return false; + } + } + return true; + } + + private setCheckboxState(isChecked: boolean) { + this.checkboxState = isChecked ? + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark all files unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as unviewed', this.label) } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark all files viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as viewed', this.label) } }; + } + + getTreeItem(): vscode.TreeItem { + this.setCheckboxState(this.allChildrenViewed()); + return this; + } +} diff --git a/src/view/treeNodes/fileChangeNode.ts b/src/view/treeNodes/fileChangeNode.ts index 1b2b8ab4c3..ae5ced508c 100644 --- a/src/view/treeNodes/fileChangeNode.ts +++ b/src/view/treeNodes/fileChangeNode.ts @@ -1,495 +1,496 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { IComment, ViewedState } from '../../common/comment'; -import { GitChangeType, InMemFileChange } from '../../common/file'; -import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; -import { asTempStorageURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toResourceUri } from '../../common/uri'; -import { groupBy } from '../../common/utils'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; -import { FileChangeModel, GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; -import { DecorationProvider } from '../treeDecorationProvider'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export function openFileCommand(uri: vscode.Uri, inputOpts: vscode.TextDocumentShowOptions = {}): vscode.Command { - const activeTextEditor = vscode.window.activeTextEditor; - const opts = { - ...inputOpts, ...{ - viewColumn: vscode.ViewColumn.Active, - } - }; - - // Check if active text editor has same path as other editor. we cannot compare via - // URI.toString() here because the schemas can be different. Instead we just go by path. - if (activeTextEditor && activeTextEditor.document.uri.path === uri.path) { - opts.selection = activeTextEditor.selection; - } - return { - command: 'vscode.open', - arguments: [uri, opts], - title: 'Open File', - }; -} - -async function openDiffCommand( - folderManager: FolderRepositoryManager, - parentFilePath: vscode.Uri, - filePath: vscode.Uri, - opts: vscode.TextDocumentShowOptions | undefined, - status: GitChangeType, -): Promise { - let parentURI = (await asTempStorageURI(parentFilePath, folderManager.repository)) || parentFilePath; - let headURI = (await asTempStorageURI(filePath, folderManager.repository)) || filePath; - if (parentURI.scheme === 'data' || headURI.scheme === 'data') { - if (status === GitChangeType.ADD) { - parentURI = EMPTY_IMAGE_URI; - } - if (status === GitChangeType.DELETE) { - headURI = EMPTY_IMAGE_URI; - } - } - - const pathSegments = filePath.path.split('/'); - return { - command: 'vscode.diff', - arguments: [parentURI, headURI, `${pathSegments[pathSegments.length - 1]} (Pull Request)`, opts], - title: 'Open Changed File in PR', - }; -} - -/** - * File change node whose content is stored in memory and resolved when being revealed. - */ -export class FileChangeNode extends TreeNode implements vscode.TreeItem { - public iconPath?: - | string - | vscode.Uri - | { light: string | vscode.Uri; dark: string | vscode.Uri } - | vscode.ThemeIcon; - public fileChangeResourceUri: vscode.Uri; - public contextValue: string; - public command: vscode.Command; - public opts: vscode.TextDocumentShowOptions; - - public checkboxState: { state: vscode.TreeItemCheckboxState; tooltip?: string; accessibilityInformation: vscode.AccessibilityInformation }; - - public childrenDisposables: vscode.Disposable[] = []; - - get status(): GitChangeType { - return this.changeModel.status; - } - - get fileName(): string { - return this.changeModel.fileName; - } - - get blobUrl(): string | undefined { - return this.changeModel.blobUrl; - } - - get sha(): string | undefined { - return this.changeModel.sha; - } - - get tooltip(): string { - return this.resourceUri.fsPath; - } - - constructor( - public parent: TreeNodeParent, - protected readonly pullRequestManager: FolderRepositoryManager, - public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly changeModel: FileChangeModel - ) { - super(); - const viewed = this.pullRequest.fileChangeViewedState[this.changeModel.fileName] ?? ViewedState.UNVIEWED; - this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' - }`; - this.label = path.basename(this.changeModel.fileName); - this.iconPath = vscode.ThemeIcon.File; - this.opts = {}; - this.updateShowOptions(); - this.fileChangeResourceUri = toResourceUri( - vscode.Uri.file(this.changeModel.fileName), - this.pullRequest.number, - this.changeModel.fileName, - this.changeModel.status, - this.changeModel.change instanceof InMemFileChange ? this.changeModel.change.previousFileName : undefined - ); - this.updateViewed(viewed); - - this.childrenDisposables.push( - this.pullRequest.onDidChangeReviewThreads(e => { - if ([...e.added, ...e.removed].some(thread => thread.path === this.changeModel.fileName)) { - this.updateShowOptions(); - } - }), - ); - - this.childrenDisposables.push( - this.pullRequest.onDidChangeFileViewedState(e => { - const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.changeModel.fileName); - if (matchingChange) { - this.updateViewed(matchingChange.viewed); - this.refresh(this); - } - }), - ); - - this.accessibilityInformation = { label: `View diffs and comments for file ${this.label}`, role: 'link' }; - } - - get resourceUri(): vscode.Uri { - return this.changeModel.filePath.with({ query: this.fileChangeResourceUri.query }); - } - - get description(): string | true { - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - if (layout === 'flat') { - return true; - } else { - return ''; - } - } - - updateViewed(viewed: ViewedState) { - this.changeModel.updateViewed(viewed); - this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' - }`; - this.checkboxState = viewed === ViewedState.VIEWED ? - { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark file as unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as unviewed', this.label ?? '') } } : - { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark file as viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as viewed', this.label ?? '') } }; - } - - public async markFileAsViewed(fromCheckboxChanged: boolean = true) { - await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'viewed'); - this.pullRequestManager.setFileViewedContext(); - } - - public async unmarkFileAsViewed(fromCheckboxChanged: boolean = true) { - await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'unviewed'); - this.pullRequestManager.setFileViewedContext(); - } - - updateFromCheckboxChanged(newState: vscode.TreeItemCheckboxState) { - const viewed = newState === vscode.TreeItemCheckboxState.Checked ? ViewedState.VIEWED : ViewedState.UNVIEWED; - this.updateViewed(viewed); - } - - updateShowOptions() { - const reviewThreads = this.pullRequest.reviewThreadsCache; - const reviewThreadsByFile = groupBy(reviewThreads, thread => thread.path); - const reviewThreadsForNode = (reviewThreadsByFile[this.changeModel.fileName] || []).filter(thread => !thread.isOutdated); - - DecorationProvider.updateFileComments( - this.fileChangeResourceUri, - this.pullRequest.number, - this.changeModel.fileName, - reviewThreadsForNode.length > 0, - ); - - if (reviewThreadsForNode.length) { - reviewThreadsForNode.sort((a, b) => a.endLine - b.endLine); - const startLine = reviewThreadsForNode[0].startLine ?? reviewThreadsForNode[0].originalStartLine; - const endLine = reviewThreadsForNode[0].endLine ?? reviewThreadsForNode[0].originalEndLine; - this.opts.selection = new vscode.Range(startLine, 0, endLine, 0); - } else { - delete this.opts.selection; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } - - openFileCommand(): vscode.Command { - return openFileCommand(this.changeModel.filePath); - } - - async openDiff(folderManager: FolderRepositoryManager, opts?: vscode.TextDocumentShowOptions): Promise { - const command = await openDiffCommand( - folderManager, - this.changeModel.parentFilePath, - this.changeModel.filePath, - { - ...this.opts, - ...opts, - }, - this.changeModel.status, - ); - return vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); - } -} - -/** - * File change node whose content can not be resolved locally and we direct users to GitHub. - */ -export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeItem { - get description(): string { - let description = vscode.workspace.asRelativePath(path.dirname(this.changeModel.fileName), false); - if (description === '.') { - description = ''; - } - return description; - } - - constructor( - public parent: TreeNodeParent, - folderRepositoryManager: FolderRepositoryManager, - pullRequest: PullRequestModel & IResolvedPullRequestModel, - changeModel: RemoteFileChangeModel - ) { - super(parent, folderRepositoryManager, pullRequest, changeModel); - this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); - this.command = { - command: 'pr.openFileOnGitHub', - title: 'Open File on GitHub', - arguments: [this], - }; - } - - async openDiff(): Promise { - return vscode.commands.executeCommand(this.command.command); - } - - openFileCommand(): vscode.Command { - return this.command; - } -} - -/** - * File change node whose content is stored in memory and resolved when being revealed. - */ -export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { - constructor( - private readonly folderRepositoryManager: FolderRepositoryManager, - public parent: TreeNodeParent, - public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly changeModel: InMemFileChangeModel - ) { - super(parent, folderRepositoryManager, pullRequest, changeModel); - } - - get comments(): IComment[] { - return this.pullRequest.comments.filter(comment => (comment.path === this.changeModel.fileName) && (comment.position !== null)); - } - - getTreeItem(): vscode.TreeItem { - return this; - } - - async resolve(): Promise { - if (this.status === GitChangeType.ADD) { - this.command = openFileCommand(this.changeModel.filePath); - } else { - this.command = await openDiffCommand( - this.folderRepositoryManager, - this.changeModel.parentFilePath, - this.changeModel.filePath, - undefined, - this.changeModel.status, - ); - } - } -} - -/** - * File change node whose content can be resolved by git commit sha. - */ -export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem { - constructor( - public parent: TreeNodeParent, - pullRequestManager: FolderRepositoryManager, - public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly changeModel: GitFileChangeModel, - private isCurrent?: boolean, - private _comments?: IComment[] - ) { - super(parent, pullRequestManager, pullRequest, changeModel); - } - - get comments(): IComment[] { - if (this._comments) { - return this._comments; - } - // if there's a commit sha, then the comment must belong to the commit. - return this.pullRequest.comments.filter(comment => { - if (!this.sha || this.sha === this.pullRequest.head.sha) { - return comment.position && (comment.path === this.changeModel.fileName); - } else { - return (comment.path === this.changeModel.fileName) && (comment.originalCommitId === this.sha); - } - }); - } - - private _useViewChangesCommand = false; - public useViewChangesCommand() { - this._useViewChangesCommand = true; - } - - private async alternateCommand(): Promise { - if (this.status === GitChangeType.DELETE || this.status === GitChangeType.ADD) { - // create an empty `review` uri without any path/commit info. - const emptyFileUri = this.changeModel.parentFilePath.with({ - query: JSON.stringify({ - path: null, - commit: null, - }), - }); - - return { - command: 'vscode.diff', - arguments: - this.status === GitChangeType.DELETE - ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, {}] - : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, {}], - title: 'Open Diff', - }; - } - - // Show the file change in a diff view. - const { path: filePath, ref, commit, rootPath } = fromReviewUri(this.changeModel.filePath.query); - const previousCommit = `${commit}^`; - const query: ReviewUriParams = { - path: filePath, - ref: ref, - commit: previousCommit, - base: true, - isOutdated: true, - rootPath, - }; - const previousFileUri = this.changeModel.filePath.with({ query: JSON.stringify(query) }); - let currentFilePath = this.changeModel.filePath; - // If the commit is the most recent/current commit, then we just use the current file for the right. - // This is so that comments display properly. - if (this.isCurrent) { - currentFilePath = this.pullRequestManager.repository.rootUri.with({ path: path.posix.join(query.rootPath, query.path) }); - } - - const options: vscode.TextDocumentShowOptions = {}; - - const reviewThreads = this.pullRequest.reviewThreadsCache; - const reviewThreadsByFile = groupBy(reviewThreads, t => t.path); - const reviewThreadsForNode = (reviewThreadsByFile[this.fileName] || []) - .filter(thread => thread.isOutdated) - .sort((a, b) => a.endLine - b.endLine); - - if (reviewThreadsForNode.length) { - options.selection = new vscode.Range(reviewThreadsForNode[0].originalStartLine, 0, reviewThreadsForNode[0].originalEndLine, 0); - } - - return { - command: 'vscode.diff', - arguments: [ - previousFileUri, - currentFilePath, - `${this.fileName} from ${(commit || '').substr(0, 8)}`, - options, - ], - title: 'View Changes', - }; - } - - async resolve(): Promise { - if (this._useViewChangesCommand) { - this.command = await this.alternateCommand(); - } else { - const openDiff = vscode.workspace.getConfiguration(GIT, this.pullRequestManager.repository.rootUri).get(OPEN_DIFF_ON_CLICK, true); - if (openDiff && this.status !== GitChangeType.ADD) { - this.command = await openDiffCommand( - this.pullRequestManager, - this.changeModel.parentFilePath, - this.changeModel.filePath, - this.opts, - this.status, - ); - } else { - this.command = this.openFileCommand(); - } - } - } -} - -/** - * File change node whose content is resolved from GitHub. For files not yet associated with a pull request. - */ -export class GitHubFileChangeNode extends TreeNode implements vscode.TreeItem { - public description: string; - public iconPath: vscode.ThemeIcon; - public fileChangeResourceUri: vscode.Uri; - - public command: vscode.Command; - - constructor( - public readonly parent: TreeNodeParent, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - public readonly status: GitChangeType, - public readonly baseBranch: string, - public readonly headBranch: string, - public readonly isLocal: boolean - ) { - super(); - const scheme = isLocal ? Schemes.GitPr : Schemes.GithubPr; - this.label = fileName; - this.iconPath = vscode.ThemeIcon.File; - this.fileChangeResourceUri = vscode.Uri.file(fileName).with({ - scheme, - query: JSON.stringify({ status, fileName }), - }); - - let parentURI = vscode.Uri.file(fileName).with({ - scheme, - query: JSON.stringify({ fileName, branch: baseBranch }), - }); - let headURI = vscode.Uri.file(fileName).with({ - scheme, - query: JSON.stringify({ fileName, branch: headBranch }), - }); - switch (status) { - case GitChangeType.ADD: - parentURI = vscode.Uri.file(fileName).with({ - scheme, - query: JSON.stringify({ fileName, branch: baseBranch, isEmpty: true }), - }); - break; - - case GitChangeType.RENAME: - parentURI = vscode.Uri.file(previousFileName!).with({ - scheme, - query: JSON.stringify({ fileName: previousFileName, branch: baseBranch, isEmpty: true }), - }); - break; - - case GitChangeType.DELETE: - headURI = vscode.Uri.file(fileName).with({ - scheme, - query: JSON.stringify({ fileName, branch: headBranch, isEmpty: true }), - }); - break; - } - - this.command = { - title: 'Open Diff', - command: 'vscode.diff', - arguments: [parentURI, headURI, `${fileName} (Pull Request Preview)`], - }; - } - - get resourceUri(): vscode.Uri { - return vscode.Uri.file(this.fileName).with({ query: this.fileChangeResourceUri.query }); - } - - getTreeItem() { - return this; - } -} - -export function gitFileChangeNodeFilter(nodes: (GitFileChangeNode | RemoteFileChangeNode)[]): GitFileChangeNode[] { - return nodes.filter(node => node instanceof GitFileChangeNode) as GitFileChangeNode[]; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { IComment, ViewedState } from '../../common/comment'; +import { GitChangeType, InMemFileChange } from '../../common/file'; +import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { asTempStorageURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toResourceUri } from '../../common/uri'; +import { groupBy } from '../../common/utils'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { FileChangeModel, GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; +import { DecorationProvider } from '../treeDecorationProvider'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export function openFileCommand(uri: vscode.Uri, inputOpts: vscode.TextDocumentShowOptions = {}): vscode.Command { + const activeTextEditor = vscode.window.activeTextEditor; + const opts = { + ...inputOpts, ...{ + viewColumn: vscode.ViewColumn.Active, + } + }; + + // Check if active text editor has same path as other editor. we cannot compare via + // URI.toString() here because the schemas can be different. Instead we just go by path. + if (activeTextEditor && activeTextEditor.document.uri.path === uri.path) { + opts.selection = activeTextEditor.selection; + } + return { + command: 'vscode.open', + arguments: [uri, opts], + title: 'Open File', + }; +} + +async function openDiffCommand( + folderManager: FolderRepositoryManager, + parentFilePath: vscode.Uri, + filePath: vscode.Uri, + opts: vscode.TextDocumentShowOptions | undefined, + status: GitChangeType, +): Promise { + let parentURI = (await asTempStorageURI(parentFilePath, folderManager.repository)) || parentFilePath; + let headURI = (await asTempStorageURI(filePath, folderManager.repository)) || filePath; + if (parentURI.scheme === 'data' || headURI.scheme === 'data') { + if (status === GitChangeType.ADD) { + parentURI = EMPTY_IMAGE_URI; + } + if (status === GitChangeType.DELETE) { + headURI = EMPTY_IMAGE_URI; + } + } + + const pathSegments = filePath.path.split('/'); + return { + command: 'vscode.diff', + arguments: [parentURI, headURI, `${pathSegments[pathSegments.length - 1]} (Pull Request)`, opts], + title: 'Open Changed File in PR', + }; +} + +/** + * File change node whose content is stored in memory and resolved when being revealed. + */ +export class FileChangeNode extends TreeNode implements vscode.TreeItem { + public iconPath?: + | string + | vscode.Uri + | { light: string | vscode.Uri; dark: string | vscode.Uri } + | vscode.ThemeIcon; + public fileChangeResourceUri: vscode.Uri; + public contextValue: string; + public command: vscode.Command; + public opts: vscode.TextDocumentShowOptions; + + public checkboxState: { state: vscode.TreeItemCheckboxState; tooltip?: string; accessibilityInformation: vscode.AccessibilityInformation }; + + public childrenDisposables: vscode.Disposable[] = []; + + get status(): GitChangeType { + return this.changeModel.status; + } + + get fileName(): string { + return this.changeModel.fileName; + } + + get blobUrl(): string | undefined { + return this.changeModel.blobUrl; + } + + get sha(): string | undefined { + return this.changeModel.sha; + } + + get tooltip(): string { + return this.resourceUri.fsPath; + } + + constructor( + public parent: TreeNodeParent, + protected readonly pullRequestManager: FolderRepositoryManager, + public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, + public readonly changeModel: FileChangeModel + ) { + super(); + const viewed = this.pullRequest.fileChangeViewedState[this.changeModel.fileName] ?? ViewedState.UNVIEWED; + this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' + }`; + this.label = path.basename(this.changeModel.fileName); + this.iconPath = vscode.ThemeIcon.File; + this.opts = {}; + this.updateShowOptions(); + this.fileChangeResourceUri = toResourceUri( + vscode.Uri.file(this.changeModel.fileName), + this.pullRequest.number, + this.changeModel.fileName, + this.changeModel.status, + this.changeModel.change instanceof InMemFileChange ? this.changeModel.change.previousFileName : undefined + ); + this.updateViewed(viewed); + + this.childrenDisposables.push( + this.pullRequest.onDidChangeReviewThreads(e => { + if ([...e.added, ...e.removed].some(thread => thread.path === this.changeModel.fileName)) { + this.updateShowOptions(); + } + }), + ); + + this.childrenDisposables.push( + this.pullRequest.onDidChangeFileViewedState(e => { + const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.changeModel.fileName); + if (matchingChange) { + this.updateViewed(matchingChange.viewed); + this.refresh(this); + } + }), + ); + + this.accessibilityInformation = { label: `View diffs and comments for file ${this.label}`, role: 'link' }; + } + + get resourceUri(): vscode.Uri { + return this.changeModel.filePath.with({ query: this.fileChangeResourceUri.query }); + } + + get description(): string | true { + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + if (layout === 'flat') { + return true; + } else { + return ''; + } + } + + updateViewed(viewed: ViewedState) { + this.changeModel.updateViewed(viewed); + this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' + }`; + this.checkboxState = viewed === ViewedState.VIEWED ? + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark file as unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as unviewed', this.label ?? '') } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark file as viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as viewed', this.label ?? '') } }; + } + + public async markFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'viewed'); + this.pullRequestManager.setFileViewedContext(); + } + + public async unmarkFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'unviewed'); + this.pullRequestManager.setFileViewedContext(); + } + + updateFromCheckboxChanged(newState: vscode.TreeItemCheckboxState) { + const viewed = newState === vscode.TreeItemCheckboxState.Checked ? ViewedState.VIEWED : ViewedState.UNVIEWED; + this.updateViewed(viewed); + } + + updateShowOptions() { + const reviewThreads = this.pullRequest.reviewThreadsCache; + const reviewThreadsByFile = groupBy(reviewThreads, thread => thread.path); + const reviewThreadsForNode = (reviewThreadsByFile[this.changeModel.fileName] || []).filter(thread => !thread.isOutdated); + + DecorationProvider.updateFileComments( + this.fileChangeResourceUri, + this.pullRequest.number, + this.changeModel.fileName, + reviewThreadsForNode.length > 0, + ); + + if (reviewThreadsForNode.length) { + reviewThreadsForNode.sort((a, b) => a.endLine - b.endLine); + const startLine = reviewThreadsForNode[0].startLine ?? reviewThreadsForNode[0].originalStartLine; + const endLine = reviewThreadsForNode[0].endLine ?? reviewThreadsForNode[0].originalEndLine; + this.opts.selection = new vscode.Range(startLine, 0, endLine, 0); + } else { + delete this.opts.selection; + } + } + + getTreeItem(): vscode.TreeItem { + return this; + } + + openFileCommand(): vscode.Command { + return openFileCommand(this.changeModel.filePath); + } + + async openDiff(folderManager: FolderRepositoryManager, opts?: vscode.TextDocumentShowOptions): Promise { + const command = await openDiffCommand( + folderManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + { + ...this.opts, + ...opts, + }, + this.changeModel.status, + ); + return vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + } +} + +/** + * File change node whose content can not be resolved locally and we direct users to GitHub. + */ +export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeItem { + get description(): string { + let description = vscode.workspace.asRelativePath(path.dirname(this.changeModel.fileName), false); + if (description === '.') { + description = ''; + } + return description; + } + + constructor( + public parent: TreeNodeParent, + folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: RemoteFileChangeModel + ) { + super(parent, folderRepositoryManager, pullRequest, changeModel); + this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); + this.command = { + command: 'pr.openFileOnGitHub', + title: 'Open File on GitHub', + arguments: [this], + }; + } + + async openDiff(): Promise { + return vscode.commands.executeCommand(this.command.command); + } + + openFileCommand(): vscode.Command { + return this.command; + } +} + +/** + * File change node whose content is stored in memory and resolved when being revealed. + */ +export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { + constructor( + private readonly folderRepositoryManager: FolderRepositoryManager, + public parent: TreeNodeParent, + public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, + public readonly changeModel: InMemFileChangeModel + ) { + super(parent, folderRepositoryManager, pullRequest, changeModel); + } + + get comments(): IComment[] { + return this.pullRequest.comments.filter(comment => (comment.path === this.changeModel.fileName) && (comment.position !== null)); + } + + getTreeItem(): vscode.TreeItem { + return this; + } + + async resolve(): Promise { + if (this.status === GitChangeType.ADD) { + this.command = openFileCommand(this.changeModel.filePath); + } else { + this.command = await openDiffCommand( + this.folderRepositoryManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + undefined, + this.changeModel.status, + ); + } + } +} + +/** + * File change node whose content can be resolved by git commit sha. + */ +export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem { + constructor( + public parent: TreeNodeParent, + pullRequestManager: FolderRepositoryManager, + public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, + public readonly changeModel: GitFileChangeModel, + private isCurrent?: boolean, + private _comments?: IComment[] + ) { + super(parent, pullRequestManager, pullRequest, changeModel); + } + + get comments(): IComment[] { + if (this._comments) { + return this._comments; + } + // if there's a commit sha, then the comment must belong to the commit. + return this.pullRequest.comments.filter(comment => { + if (!this.sha || this.sha === this.pullRequest.head.sha) { + return comment.position && (comment.path === this.changeModel.fileName); + } else { + return (comment.path === this.changeModel.fileName) && (comment.originalCommitId === this.sha); + } + }); + } + + private _useViewChangesCommand = false; + public useViewChangesCommand() { + this._useViewChangesCommand = true; + } + + private async alternateCommand(): Promise { + if (this.status === GitChangeType.DELETE || this.status === GitChangeType.ADD) { + // create an empty `review` uri without any path/commit info. + const emptyFileUri = this.changeModel.parentFilePath.with({ + query: JSON.stringify({ + path: null, + commit: null, + }), + }); + + return { + command: 'vscode.diff', + arguments: + this.status === GitChangeType.DELETE + ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, {}] + : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, {}], + title: 'Open Diff', + }; + } + + // Show the file change in a diff view. + const { path: filePath, ref, commit, rootPath } = fromReviewUri(this.changeModel.filePath.query); + const previousCommit = `${commit}^`; + const query: ReviewUriParams = { + path: filePath, + ref: ref, + commit: previousCommit, + base: true, + isOutdated: true, + rootPath, + }; + const previousFileUri = this.changeModel.filePath.with({ query: JSON.stringify(query) }); + let currentFilePath = this.changeModel.filePath; + // If the commit is the most recent/current commit, then we just use the current file for the right. + // This is so that comments display properly. + if (this.isCurrent) { + currentFilePath = this.pullRequestManager.repository.rootUri.with({ path: path.posix.join(query.rootPath, query.path) }); + } + + const options: vscode.TextDocumentShowOptions = {}; + + const reviewThreads = this.pullRequest.reviewThreadsCache; + const reviewThreadsByFile = groupBy(reviewThreads, t => t.path); + const reviewThreadsForNode = (reviewThreadsByFile[this.fileName] || []) + .filter(thread => thread.isOutdated) + .sort((a, b) => a.endLine - b.endLine); + + if (reviewThreadsForNode.length) { + options.selection = new vscode.Range(reviewThreadsForNode[0].originalStartLine, 0, reviewThreadsForNode[0].originalEndLine, 0); + } + + return { + command: 'vscode.diff', + arguments: [ + previousFileUri, + currentFilePath, + `${this.fileName} from ${(commit || '').substr(0, 8)}`, + options, + ], + title: 'View Changes', + }; + } + + async resolve(): Promise { + if (this._useViewChangesCommand) { + this.command = await this.alternateCommand(); + } else { + const openDiff = vscode.workspace.getConfiguration(GIT, this.pullRequestManager.repository.rootUri).get(OPEN_DIFF_ON_CLICK, true); + if (openDiff && this.status !== GitChangeType.ADD) { + this.command = await openDiffCommand( + this.pullRequestManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + this.opts, + this.status, + ); + } else { + this.command = this.openFileCommand(); + } + } + } +} + +/** + * File change node whose content is resolved from GitHub. For files not yet associated with a pull request. + */ +export class GitHubFileChangeNode extends TreeNode implements vscode.TreeItem { + public description: string; + public iconPath: vscode.ThemeIcon; + public fileChangeResourceUri: vscode.Uri; + + public command: vscode.Command; + + constructor( + public readonly parent: TreeNodeParent, + public readonly fileName: string, + public readonly previousFileName: string | undefined, + public readonly status: GitChangeType, + public readonly baseBranch: string, + public readonly headBranch: string, + public readonly isLocal: boolean + ) { + super(); + const scheme = isLocal ? Schemes.GitPr : Schemes.GithubPr; + this.label = fileName; + this.iconPath = vscode.ThemeIcon.File; + this.fileChangeResourceUri = vscode.Uri.file(fileName).with({ + scheme, + query: JSON.stringify({ status, fileName }), + }); + + let parentURI = vscode.Uri.file(fileName).with({ + scheme, + query: JSON.stringify({ fileName, branch: baseBranch }), + }); + let headURI = vscode.Uri.file(fileName).with({ + scheme, + query: JSON.stringify({ fileName, branch: headBranch }), + }); + switch (status) { + case GitChangeType.ADD: + parentURI = vscode.Uri.file(fileName).with({ + scheme, + query: JSON.stringify({ fileName, branch: baseBranch, isEmpty: true }), + }); + break; + + case GitChangeType.RENAME: + parentURI = vscode.Uri.file(previousFileName!).with({ + scheme, + query: JSON.stringify({ fileName: previousFileName, branch: baseBranch, isEmpty: true }), + }); + break; + + case GitChangeType.DELETE: + headURI = vscode.Uri.file(fileName).with({ + scheme, + query: JSON.stringify({ fileName, branch: headBranch, isEmpty: true }), + }); + break; + } + + this.command = { + title: 'Open Diff', + command: 'vscode.diff', + arguments: [parentURI, headURI, `${fileName} (Pull Request Preview)`], + }; + } + + get resourceUri(): vscode.Uri { + return vscode.Uri.file(this.fileName).with({ query: this.fileChangeResourceUri.query }); + } + + getTreeItem() { + return this; + } +} + +export function gitFileChangeNodeFilter(nodes: (GitFileChangeNode | RemoteFileChangeNode)[]): GitFileChangeNode[] { + return nodes.filter(node => node instanceof GitFileChangeNode) as GitFileChangeNode[]; +} diff --git a/src/view/treeNodes/filesCategoryNode.ts b/src/view/treeNodes/filesCategoryNode.ts index 625bca40d9..486f1d6395 100644 --- a/src/view/treeNodes/filesCategoryNode.ts +++ b/src/view/treeNodes/filesCategoryNode.ts @@ -1,85 +1,86 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import Logger, { PR_TREE } from '../../common/logger'; -import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { ReviewModel } from '../reviewModel'; -import { DirectoryTreeNode } from './directoryTreeNode'; -import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; - -export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { - public label: string = vscode.l10n.t('Files'); - public collapsibleState: vscode.TreeItemCollapsibleState; - private directories: TreeNode[] = []; - - constructor( - public parent: TreeNodeParent, - private _reviewModel: ReviewModel, - _pullRequestModel: PullRequestModel - ) { - super(); - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - this.childrenDisposables = []; - this.childrenDisposables.push(this._reviewModel.onDidChangeLocalFileChanges(() => { - Logger.appendLine(`Local files have changed, refreshing Files node`, PR_TREE); - this.refresh(this); - })); - this.childrenDisposables.push(_pullRequestModel.onDidChangeReviewThreads(() => { - Logger.appendLine(`Review threads have changed, refreshing Files node`, PR_TREE); - this.refresh(this); - })); - this.childrenDisposables.push(_pullRequestModel.onDidChangeComments(() => { - Logger.appendLine(`Comments have changed, refreshing Files node`, PR_TREE); - this.refresh(this); - })); - } - - getTreeItem(): vscode.TreeItem { - return this; - } - - async getChildren(): Promise { - super.getChildren(); - - Logger.appendLine(`Getting children for Files node`, PR_TREE); - if (!this._reviewModel.hasLocalFileChanges) { - // Provide loading feedback until we get the files. - return new Promise(resolve => { - const promiseResolver = this._reviewModel.onDidChangeLocalFileChanges(() => { - resolve([]); - promiseResolver.dispose(); - }); - }); - } - - if (this._reviewModel.localFileChanges.length === 0) { - return [new LabelOnlyNode(vscode.l10n.t('No changed files'))]; - } - - let nodes: TreeNode[]; - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - - const dirNode = new DirectoryTreeNode(this, ''); - this._reviewModel.localFileChanges.forEach(f => dirNode.addFile(f)); - dirNode.finalize(); - if (dirNode.label === '') { - // nothing on the root changed, pull children to parent - this.directories = dirNode.children; - } else { - this.directories = [dirNode]; - } - - if (layout === 'tree') { - nodes = this.directories; - } else { - nodes = this._reviewModel.localFileChanges; - } - Logger.appendLine(`Got all children for Files node`, PR_TREE); - this.children = nodes; - return nodes; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import Logger, { PR_TREE } from '../../common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { ReviewModel } from '../reviewModel'; +import { DirectoryTreeNode } from './directoryTreeNode'; +import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; + +export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { + public label: string = vscode.l10n.t('Files'); + public collapsibleState: vscode.TreeItemCollapsibleState; + private directories: TreeNode[] = []; + + constructor( + public parent: TreeNodeParent, + private _reviewModel: ReviewModel, + _pullRequestModel: PullRequestModel + ) { + super(); + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + this.childrenDisposables = []; + this.childrenDisposables.push(this._reviewModel.onDidChangeLocalFileChanges(() => { + Logger.appendLine(`Local files have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + })); + this.childrenDisposables.push(_pullRequestModel.onDidChangeReviewThreads(() => { + Logger.appendLine(`Review threads have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + })); + this.childrenDisposables.push(_pullRequestModel.onDidChangeComments(() => { + Logger.appendLine(`Comments have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + })); + } + + getTreeItem(): vscode.TreeItem { + return this; + } + + async getChildren(): Promise { + super.getChildren(); + + Logger.appendLine(`Getting children for Files node`, PR_TREE); + if (!this._reviewModel.hasLocalFileChanges) { + // Provide loading feedback until we get the files. + return new Promise(resolve => { + const promiseResolver = this._reviewModel.onDidChangeLocalFileChanges(() => { + resolve([]); + promiseResolver.dispose(); + }); + }); + } + + if (this._reviewModel.localFileChanges.length === 0) { + return [new LabelOnlyNode(vscode.l10n.t('No changed files'))]; + } + + let nodes: TreeNode[]; + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + + const dirNode = new DirectoryTreeNode(this, ''); + this._reviewModel.localFileChanges.forEach(f => dirNode.addFile(f)); + dirNode.finalize(); + if (dirNode.label === '') { + // nothing on the root changed, pull children to parent + this.directories = dirNode.children; + } else { + this.directories = [dirNode]; + } + + if (layout === 'tree') { + nodes = this.directories; + } else { + nodes = this._reviewModel.localFileChanges; + } + Logger.appendLine(`Got all children for Files node`, PR_TREE); + this.children = nodes; + return nodes; + } +} diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 0365c8bd92..211dc742a8 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -1,377 +1,378 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Repository } from '../../api/api'; -import { getCommentingRanges } from '../../common/commentingRanges'; -import { InMemFileChange, SlimFileChange } from '../../common/file'; -import Logger from '../../common/logger'; -import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, SHOW_PULL_REQUEST_NUMBER_IN_TREE } from '../../common/settingKeys'; -import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; -import { dispose } from '../../common/utils'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { NotificationProvider } from '../../github/notifications'; -import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; -import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; -import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; -import { DescriptionNode } from './descriptionNode'; -import { DirectoryTreeNode } from './directoryTreeNode'; -import { InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { - static ID = 'PRNode'; - - private _fileChanges: (RemoteFileChangeNode | InMemFileChangeNode)[] | undefined; - private _commentController?: vscode.CommentController; - private _disposables: vscode.Disposable[] = []; - - private _inMemPRContentProvider?: vscode.Disposable; - - private _command: vscode.Command; - - public get command(): vscode.Command { - return this._command; - } - - public set command(newCommand: vscode.Command) { - this._command = newCommand; - } - - public get repository(): Repository { - return this._folderReposManager.repository; - } - - constructor( - public parent: TreeNodeParent, - private _folderReposManager: FolderRepositoryManager, - public pullRequestModel: PullRequestModel, - private _isLocal: boolean, - private _notificationProvider: NotificationProvider - ) { - super(); - this.registerSinceReviewChange(); - this.registerConfigurationChange(); - this._disposables.push(this.pullRequestModel.onDidInvalidate(() => this.refresh(this))); - this._disposables.push(this._folderReposManager.onDidChangeActivePullRequest(e => { - if (e.new === this.pullRequestModel.number || e.old === this.pullRequestModel.number) { - this.refresh(this); - } - })); - } - - // #region Tree - async getChildren(): Promise { - super.getChildren(); - Logger.debug(`Fetch children of PRNode #${this.pullRequestModel.number}`, PRNode.ID); - - try { - const descriptionNode = new DescriptionNode( - this, - vscode.l10n.t('Description'), - this.pullRequestModel, - this.repository, - this._folderReposManager - ); - - if (!this.pullRequestModel.isResolved()) { - return [descriptionNode]; - } - - [, this._fileChanges, ,] = await Promise.all([ - this.pullRequestModel.initializePullRequestFileViewState(), - this.resolveFileChangeNodes(), - (!this._commentController) ? this.resolvePRCommentController() : new Promise(resolve => resolve()), - this.pullRequestModel.validateDraftMode() - ]); - - if (!this._inMemPRContentProvider) { - this._inMemPRContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( - this.pullRequestModel.number, - this.provideDocumentContent.bind(this), - ); - } - - const result: TreeNode[] = [descriptionNode]; - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - if (layout === 'tree') { - // tree view - const dirNode = new DirectoryTreeNode(this, ''); - this._fileChanges.forEach(f => dirNode.addFile(f)); - dirNode.finalize(); - if (dirNode.label === '') { - // nothing on the root changed, pull children to parent - result.push(...dirNode.children); - } else { - result.push(dirNode); - } - } else { - // flat view - result.push(...this._fileChanges); - } - - if (this.pullRequestModel.showChangesSinceReview !== undefined) { - this.reopenNewPrDiffs(this.pullRequestModel); - } - - this.children = result; - - // Kick off review thread initialization but don't await it. - // Events will be fired later that will cause the tree to update when this is ready. - this.pullRequestModel.initializeReviewThreadCache(); - - return result; - } catch (e) { - Logger.error(e); - return []; - } - } - - protected registerSinceReviewChange() { - this._disposables.push( - this.pullRequestModel.onDidChangeChangesSinceReview(_ => { - this.refresh(this); - }) - ); - } - - protected registerConfigurationChange() { - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_PULL_REQUEST_NUMBER_IN_TREE}`)) { - this.refresh(); - } - }) - ); - } - - public async reopenNewPrDiffs(pullRequest: PullRequestModel) { - let hasOpenDiff: boolean = false; - vscode.window.tabGroups.all.map(tabGroup => { - tabGroup.tabs.map(tab => { - if ( - tab.input instanceof vscode.TabInputTextDiff && - tab.input.original.scheme === Schemes.Pr && - tab.input.modified.scheme === Schemes.Pr && - this._fileChanges - ) { - for (const localChange of this._fileChanges) { - - const originalParams = fromPRUri(tab.input.original); - const modifiedParams = fromPRUri(tab.input.modified); - const newLocalChangeParams = fromPRUri(localChange.changeModel.filePath); - if ( - originalParams?.prNumber === pullRequest.number && - modifiedParams?.prNumber === pullRequest.number && - localChange.fileName === modifiedParams.fileName && - newLocalChangeParams?.headCommit !== modifiedParams.headCommit - ) { - hasOpenDiff = true; - vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderReposManager, { preview: tab.isPreview })); - break; - } - } - } - }); - }); - if (pullRequest.showChangesSinceReview && !hasOpenDiff && this._fileChanges && this._fileChanges.length && !pullRequest.isActive) { - this._fileChanges[0].openDiff(this._folderReposManager, { preview: true }); - } - } - - private async resolvePRCommentController(): Promise { - // If the current branch is this PR's branch, then we can rely on the review comment controller instead. - if (this.pullRequestModel.equals(this._folderReposManager.activePullRequest)) { - return; - } - - await this.pullRequestModel.githubRepository.ensureCommentsController(); - this._commentController = this.pullRequestModel.githubRepository.commentsController!; - - this._disposables.push( - this.pullRequestModel.githubRepository.commentsHandler!.registerCommentingRangeProvider( - this.pullRequestModel.number, - this, - ), - ); - - this._disposables.push( - this.pullRequestModel.githubRepository.commentsHandler!.registerCommentController( - this.pullRequestModel.number, - this.pullRequestModel, - this._folderReposManager, - ), - ); - - this.registerListeners(); - } - - private registerListeners(): void { - this._disposables.push( - this.pullRequestModel.onDidChangePendingReviewState(async newDraftMode => { - if (!newDraftMode) { - (await this.getFileChanges()).forEach(fileChange => { - if (fileChange instanceof InMemFileChangeNode) { - fileChange.comments.forEach(c => (c.isDraft = newDraftMode)); - } - }); - } - }), - ); - } - - public async getFileChanges(noCache: boolean | void): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { - if (!this._fileChanges || noCache) { - this._fileChanges = await this.resolveFileChangeNodes(); - } - - return this._fileChanges; - } - - private async resolveFileChangeNodes(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { - if (!this.pullRequestModel.isResolved()) { - return []; - } - - // If this PR is the the current PR, then we should be careful to use - // URIs that will cause the review comment controller to be used. - const rawChanges: (SlimFileChange | InMemFileChange)[] = []; - const isCurrentPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); - if (isCurrentPR && (this._folderReposManager.activePullRequest !== undefined) && (this._folderReposManager.activePullRequest.fileChanges.size > 0)) { - this.pullRequestModel = this._folderReposManager.activePullRequest; - rawChanges.push(...this._folderReposManager.activePullRequest.fileChanges.values()); - } else { - rawChanges.push(...await this.pullRequestModel.getFileChangesInfo()); - } - - // Merge base is set as part of getFileChangesInfo - const mergeBase = this.pullRequestModel.mergeBase; - if (!mergeBase) { - return []; - } - - return rawChanges.map(change => { - if (change instanceof SlimFileChange) { - const changeModel = new RemoteFileChangeModel(this._folderReposManager, change, this.pullRequestModel); - return new RemoteFileChangeNode( - this, - this._folderReposManager, - this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), - changeModel - ); - } - - const changeModel = new InMemFileChangeModel(this._folderReposManager, - this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), - change, isCurrentPR, mergeBase); - const changedItem = new InMemFileChangeNode( - this._folderReposManager, - this, - this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), - changeModel - ); - - return changedItem; - }); - } - - async getTreeItem(): Promise { - const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); - - const { title, number, author, isDraft, html_url } = this.pullRequestModel; - - const { login } = author; - - const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel); - - const formattedPRNumber = number.toString(); - let labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; - let tooltipPrefix = currentBranchIsForThisPR ? 'Current Branch * ' : ''; - - if ( - vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) - ) { - labelPrefix += `#${formattedPRNumber}: `; - tooltipPrefix += `#${formattedPRNumber}: `; - } - - const label = `${labelPrefix}${isDraft ? '[DRAFT] ' : ''}${title}`; - const tooltip = `${tooltipPrefix}${title} by @${login}`; - const description = `by @${login}`; - - return { - label, - id: `${this.parent instanceof TreeNode ? (this.parent.id ?? this.parent.label) : ''}${html_url}${this._isLocal ? this.pullRequestModel.localBranchName : ''}`, // unique id stable across checkout status - tooltip, - description, - collapsibleState: 1, - contextValue: - 'pullrequest' + - (this._isLocal ? ':local' : '') + - (currentBranchIsForThisPR ? ':active' : ':nonactive') + - (hasNotification ? ':notification' : ''), - iconPath: (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0] - ?? new vscode.ThemeIcon('github'), - accessibilityInformation: { - label: `${isDraft ? 'Draft ' : ''}Pull request number ${formattedPRNumber}: ${title} by ${login}` - }, - resourceUri: createPRNodeUri(this.pullRequestModel), - }; - } - - async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { - if (document.uri.scheme === Schemes.Pr) { - const params = fromPRUri(document.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return undefined; - } - - const fileChange = (await this.getFileChanges()).find(change => change.changeModel.fileName === params.fileName); - - if (!fileChange || fileChange instanceof RemoteFileChangeNode) { - return undefined; - } - - return { ranges: getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID), fileComments: true }; - } - - return undefined; - } - - // #region Document Content Provider - private async provideDocumentContent(uri: vscode.Uri): Promise { - const params = fromPRUri(uri); - if (!params) { - return ''; - } - - const fileChange = (await this.getFileChanges()).find( - contentChange => contentChange.changeModel.fileName === params.fileName, - )?.changeModel; - - if (!fileChange) { - Logger.appendLine(`Can not find content for document ${uri.toString()}`, 'PR'); - return ''; - } - - return provideDocumentContentForChangeModel(this._folderReposManager, this.pullRequestModel, params, fileChange); - } - - dispose(): void { - super.dispose(); - - if (this._inMemPRContentProvider) { - this._inMemPRContentProvider.dispose(); - } - - this._commentController = undefined; - - dispose(this._disposables); - this._disposables = []; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import { Repository } from '../../api/api'; +import { getCommentingRanges } from '../../common/commentingRanges'; +import { InMemFileChange, SlimFileChange } from '../../common/file'; +import Logger from '../../common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, SHOW_PULL_REQUEST_NUMBER_IN_TREE } from '../../common/settingKeys'; +import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; +import { dispose } from '../../common/utils'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { NotificationProvider } from '../../github/notifications'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; +import { DescriptionNode } from './descriptionNode'; +import { DirectoryTreeNode } from './directoryTreeNode'; +import { InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; + +export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { + static ID = 'PRNode'; + + private _fileChanges: (RemoteFileChangeNode | InMemFileChangeNode)[] | undefined; + private _commentController?: vscode.CommentController; + private _disposables: vscode.Disposable[] = []; + + private _inMemPRContentProvider?: vscode.Disposable; + + private _command: vscode.Command; + + public get command(): vscode.Command { + return this._command; + } + + public set command(newCommand: vscode.Command) { + this._command = newCommand; + } + + public get repository(): Repository { + return this._folderReposManager.repository; + } + + constructor( + public parent: TreeNodeParent, + private _folderReposManager: FolderRepositoryManager, + public pullRequestModel: PullRequestModel, + private _isLocal: boolean, + private _notificationProvider: NotificationProvider + ) { + super(); + this.registerSinceReviewChange(); + this.registerConfigurationChange(); + this._disposables.push(this.pullRequestModel.onDidInvalidate(() => this.refresh(this))); + this._disposables.push(this._folderReposManager.onDidChangeActivePullRequest(e => { + if (e.new === this.pullRequestModel.number || e.old === this.pullRequestModel.number) { + this.refresh(this); + } + })); + } + + // #region Tree + async getChildren(): Promise { + super.getChildren(); + Logger.debug(`Fetch children of PRNode #${this.pullRequestModel.number}`, PRNode.ID); + + try { + const descriptionNode = new DescriptionNode( + this, + vscode.l10n.t('Description'), + this.pullRequestModel, + this.repository, + this._folderReposManager + ); + + if (!this.pullRequestModel.isResolved()) { + return [descriptionNode]; + } + + [, this._fileChanges, ,] = await Promise.all([ + this.pullRequestModel.initializePullRequestFileViewState(), + this.resolveFileChangeNodes(), + (!this._commentController) ? this.resolvePRCommentController() : new Promise(resolve => resolve()), + this.pullRequestModel.validateDraftMode() + ]); + + if (!this._inMemPRContentProvider) { + this._inMemPRContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( + this.pullRequestModel.number, + this.provideDocumentContent.bind(this), + ); + } + + const result: TreeNode[] = [descriptionNode]; + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + if (layout === 'tree') { + // tree view + const dirNode = new DirectoryTreeNode(this, ''); + this._fileChanges.forEach(f => dirNode.addFile(f)); + dirNode.finalize(); + if (dirNode.label === '') { + // nothing on the root changed, pull children to parent + result.push(...dirNode.children); + } else { + result.push(dirNode); + } + } else { + // flat view + result.push(...this._fileChanges); + } + + if (this.pullRequestModel.showChangesSinceReview !== undefined) { + this.reopenNewPrDiffs(this.pullRequestModel); + } + + this.children = result; + + // Kick off review thread initialization but don't await it. + // Events will be fired later that will cause the tree to update when this is ready. + this.pullRequestModel.initializeReviewThreadCache(); + + return result; + } catch (e) { + Logger.error(e); + return []; + } + } + + protected registerSinceReviewChange() { + this._disposables.push( + this.pullRequestModel.onDidChangeChangesSinceReview(_ => { + this.refresh(this); + }) + ); + } + + protected registerConfigurationChange() { + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_PULL_REQUEST_NUMBER_IN_TREE}`)) { + this.refresh(); + } + }) + ); + } + + public async reopenNewPrDiffs(pullRequest: PullRequestModel) { + let hasOpenDiff: boolean = false; + vscode.window.tabGroups.all.map(tabGroup => { + tabGroup.tabs.map(tab => { + if ( + tab.input instanceof vscode.TabInputTextDiff && + tab.input.original.scheme === Schemes.Pr && + tab.input.modified.scheme === Schemes.Pr && + this._fileChanges + ) { + for (const localChange of this._fileChanges) { + + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + const newLocalChangeParams = fromPRUri(localChange.changeModel.filePath); + if ( + originalParams?.prNumber === pullRequest.number && + modifiedParams?.prNumber === pullRequest.number && + localChange.fileName === modifiedParams.fileName && + newLocalChangeParams?.headCommit !== modifiedParams.headCommit + ) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderReposManager, { preview: tab.isPreview })); + break; + } + } + } + }); + }); + if (pullRequest.showChangesSinceReview && !hasOpenDiff && this._fileChanges && this._fileChanges.length && !pullRequest.isActive) { + this._fileChanges[0].openDiff(this._folderReposManager, { preview: true }); + } + } + + private async resolvePRCommentController(): Promise { + // If the current branch is this PR's branch, then we can rely on the review comment controller instead. + if (this.pullRequestModel.equals(this._folderReposManager.activePullRequest)) { + return; + } + + await this.pullRequestModel.githubRepository.ensureCommentsController(); + this._commentController = this.pullRequestModel.githubRepository.commentsController!; + + this._disposables.push( + this.pullRequestModel.githubRepository.commentsHandler!.registerCommentingRangeProvider( + this.pullRequestModel.number, + this, + ), + ); + + this._disposables.push( + this.pullRequestModel.githubRepository.commentsHandler!.registerCommentController( + this.pullRequestModel.number, + this.pullRequestModel, + this._folderReposManager, + ), + ); + + this.registerListeners(); + } + + private registerListeners(): void { + this._disposables.push( + this.pullRequestModel.onDidChangePendingReviewState(async newDraftMode => { + if (!newDraftMode) { + (await this.getFileChanges()).forEach(fileChange => { + if (fileChange instanceof InMemFileChangeNode) { + fileChange.comments.forEach(c => (c.isDraft = newDraftMode)); + } + }); + } + }), + ); + } + + public async getFileChanges(noCache: boolean | void): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + if (!this._fileChanges || noCache) { + this._fileChanges = await this.resolveFileChangeNodes(); + } + + return this._fileChanges; + } + + private async resolveFileChangeNodes(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + if (!this.pullRequestModel.isResolved()) { + return []; + } + + // If this PR is the the current PR, then we should be careful to use + // URIs that will cause the review comment controller to be used. + const rawChanges: (SlimFileChange | InMemFileChange)[] = []; + const isCurrentPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + if (isCurrentPR && (this._folderReposManager.activePullRequest !== undefined) && (this._folderReposManager.activePullRequest.fileChanges.size > 0)) { + this.pullRequestModel = this._folderReposManager.activePullRequest; + rawChanges.push(...this._folderReposManager.activePullRequest.fileChanges.values()); + } else { + rawChanges.push(...await this.pullRequestModel.getFileChangesInfo()); + } + + // Merge base is set as part of getFileChangesInfo + const mergeBase = this.pullRequestModel.mergeBase; + if (!mergeBase) { + return []; + } + + return rawChanges.map(change => { + if (change instanceof SlimFileChange) { + const changeModel = new RemoteFileChangeModel(this._folderReposManager, change, this.pullRequestModel); + return new RemoteFileChangeNode( + this, + this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + changeModel + ); + } + + const changeModel = new InMemFileChangeModel(this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); + const changedItem = new InMemFileChangeNode( + this._folderReposManager, + this, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + changeModel + ); + + return changedItem; + }); + } + + async getTreeItem(): Promise { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + + const { title, number, author, isDraft, html_url } = this.pullRequestModel; + + const { login } = author; + + const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel); + + const formattedPRNumber = number.toString(); + let labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; + let tooltipPrefix = currentBranchIsForThisPR ? 'Current Branch * ' : ''; + + if ( + vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) + ) { + labelPrefix += `#${formattedPRNumber}: `; + tooltipPrefix += `#${formattedPRNumber}: `; + } + + const label = `${labelPrefix}${isDraft ? '[DRAFT] ' : ''}${title}`; + const tooltip = `${tooltipPrefix}${title} by @${login}`; + const description = `by @${login}`; + + return { + label, + id: `${this.parent instanceof TreeNode ? (this.parent.id ?? this.parent.label) : ''}${html_url}${this._isLocal ? this.pullRequestModel.localBranchName : ''}`, // unique id stable across checkout status + tooltip, + description, + collapsibleState: 1, + contextValue: + 'pullrequest' + + (this._isLocal ? ':local' : '') + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (hasNotification ? ':notification' : ''), + iconPath: (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0] + ?? new vscode.ThemeIcon('github'), + accessibilityInformation: { + label: `${isDraft ? 'Draft ' : ''}Pull request number ${formattedPRNumber}: ${title} by ${login}` + }, + resourceUri: createPRNodeUri(this.pullRequestModel), + }; + } + + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { + if (document.uri.scheme === Schemes.Pr) { + const params = fromPRUri(document.uri); + + if (!params || params.prNumber !== this.pullRequestModel.number) { + return undefined; + } + + const fileChange = (await this.getFileChanges()).find(change => change.changeModel.fileName === params.fileName); + + if (!fileChange || fileChange instanceof RemoteFileChangeNode) { + return undefined; + } + + return { ranges: getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID), fileComments: true }; + } + + return undefined; + } + + // #region Document Content Provider + private async provideDocumentContent(uri: vscode.Uri): Promise { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + + const fileChange = (await this.getFileChanges()).find( + contentChange => contentChange.changeModel.fileName === params.fileName, + )?.changeModel; + + if (!fileChange) { + Logger.appendLine(`Can not find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(this._folderReposManager, this.pullRequestModel, params, fileChange); + } + + dispose(): void { + super.dispose(); + + if (this._inMemPRContentProvider) { + this._inMemPRContentProvider.dispose(); + } + + this._commentController = undefined; + + dispose(this._disposables); + this._disposables = []; + } +} diff --git a/src/view/treeNodes/repositoryChangesNode.ts b/src/view/treeNodes/repositoryChangesNode.ts index d5f497c985..fc46890d96 100644 --- a/src/view/treeNodes/repositoryChangesNode.ts +++ b/src/view/treeNodes/repositoryChangesNode.ts @@ -1,118 +1,119 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import Logger, { PR_TREE } from '../../common/logger'; -import { AUTO_REVEAL, EXPLORER } from '../../common/settingKeys'; -import { DataUri, Schemes } from '../../common/uri'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { ProgressHelper } from '../progress'; -import { ReviewModel } from '../reviewModel'; -import { CommitsNode } from './commitsCategoryNode'; -import { DescriptionNode } from './descriptionNode'; -import { FilesCategoryNode } from './filesCategoryNode'; -import { BaseTreeNode, TreeNode } from './treeNode'; - -export class RepositoryChangesNode extends DescriptionNode implements vscode.TreeItem { - private _filesCategoryNode?: FilesCategoryNode; - private _commitsCategoryNode?: CommitsNode; - public description?: string; - readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - - private _disposables: vscode.Disposable[] = []; - - constructor( - public parent: BaseTreeNode, - private _pullRequest: PullRequestModel, - private _pullRequestManager: FolderRepositoryManager, - private _reviewModel: ReviewModel, - private _progress: ProgressHelper - ) { - super(parent, _pullRequest.title, _pullRequest, _pullRequestManager.repository, _pullRequestManager); - // Cause tree values to be filled - this.getTreeItem(); - - this._disposables.push( - vscode.window.onDidChangeActiveTextEditor(e => { - if (vscode.workspace.getConfiguration(EXPLORER).get(AUTO_REVEAL)) { - const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - if (tabInput instanceof vscode.TabInputTextDiff) { - if ((tabInput.original.scheme === Schemes.Review) - && (tabInput.modified.scheme !== Schemes.Review) - && (tabInput.original.path.startsWith('/commit'))) { - return; - } - } - const activeEditorUri = e?.document.uri.toString(); - this.revealActiveEditorInTree(activeEditorUri); - } - }), - ); - - this._disposables.push( - this.parent.view.onDidChangeVisibility(_ => { - const activeEditorUri = vscode.window.activeTextEditor?.document.uri.toString(); - this.revealActiveEditorInTree(activeEditorUri); - }), - ); - - this._disposables.push(_pullRequest.onDidInvalidate(() => { - this.refresh(); - })); - } - - private revealActiveEditorInTree(activeEditorUri: string | undefined): void { - if (this.parent.view.visible && activeEditorUri) { - const matchingFile = this._reviewModel.localFileChanges.find(change => change.changeModel.filePath.toString() === activeEditorUri); - if (matchingFile) { - this.reveal(matchingFile, { select: true }); - } - } - } - - async getChildren(): Promise { - await this._progress.progress; - if (!this._filesCategoryNode || !this._commitsCategoryNode) { - Logger.appendLine(`Creating file and commit nodes for PR #${this.pullRequestModel.number}`, PR_TREE); - this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this._pullRequest); - this._commitsCategoryNode = new CommitsNode( - this.parent, - this._pullRequestManager, - this._pullRequest, - ); - } - this.children = [this._filesCategoryNode, this._commitsCategoryNode]; - return this.children; - } - - async getTreeItem(): Promise { - this.label = this._pullRequest.title; - this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this._pullRequestManager.context, [this._pullRequest.author], 16, 16))[0]; - this.description = undefined; - if (this.parent.children?.length && this.parent.children.length > 1) { - const allSameOwner = this.parent.children.every(child => { - return child instanceof RepositoryChangesNode && child.pullRequestModel.remote.owner === this.pullRequestModel.remote.owner; - }); - if (allSameOwner) { - this.description = this._pullRequest.remote.repositoryName; - } else { - this.description = `${this._pullRequest.remote.owner}/${this._pullRequest.remote.repositoryName}`; - } - if (this.label.length > 35) { - this.tooltip = this.label; - this.label = `${this.label.substring(0, 35)}...`; - } - } - this.updateContextValue(); - return this; - } - - dispose() { - super.dispose(); - this._disposables.forEach(d => d.dispose()); - this._disposables = []; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import Logger, { PR_TREE } from '../../common/logger'; +import { AUTO_REVEAL, EXPLORER } from '../../common/settingKeys'; +import { DataUri, Schemes } from '../../common/uri'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { ProgressHelper } from '../progress'; +import { ReviewModel } from '../reviewModel'; +import { CommitsNode } from './commitsCategoryNode'; +import { DescriptionNode } from './descriptionNode'; +import { FilesCategoryNode } from './filesCategoryNode'; +import { BaseTreeNode, TreeNode } from './treeNode'; + +export class RepositoryChangesNode extends DescriptionNode implements vscode.TreeItem { + private _filesCategoryNode?: FilesCategoryNode; + private _commitsCategoryNode?: CommitsNode; + public description?: string; + readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + + private _disposables: vscode.Disposable[] = []; + + constructor( + public parent: BaseTreeNode, + private _pullRequest: PullRequestModel, + private _pullRequestManager: FolderRepositoryManager, + private _reviewModel: ReviewModel, + private _progress: ProgressHelper + ) { + super(parent, _pullRequest.title, _pullRequest, _pullRequestManager.repository, _pullRequestManager); + // Cause tree values to be filled + this.getTreeItem(); + + this._disposables.push( + vscode.window.onDidChangeActiveTextEditor(e => { + if (vscode.workspace.getConfiguration(EXPLORER).get(AUTO_REVEAL)) { + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + if (tabInput instanceof vscode.TabInputTextDiff) { + if ((tabInput.original.scheme === Schemes.Review) + && (tabInput.modified.scheme !== Schemes.Review) + && (tabInput.original.path.startsWith('/commit'))) { + return; + } + } + const activeEditorUri = e?.document.uri.toString(); + this.revealActiveEditorInTree(activeEditorUri); + } + }), + ); + + this._disposables.push( + this.parent.view.onDidChangeVisibility(_ => { + const activeEditorUri = vscode.window.activeTextEditor?.document.uri.toString(); + this.revealActiveEditorInTree(activeEditorUri); + }), + ); + + this._disposables.push(_pullRequest.onDidInvalidate(() => { + this.refresh(); + })); + } + + private revealActiveEditorInTree(activeEditorUri: string | undefined): void { + if (this.parent.view.visible && activeEditorUri) { + const matchingFile = this._reviewModel.localFileChanges.find(change => change.changeModel.filePath.toString() === activeEditorUri); + if (matchingFile) { + this.reveal(matchingFile, { select: true }); + } + } + } + + async getChildren(): Promise { + await this._progress.progress; + if (!this._filesCategoryNode || !this._commitsCategoryNode) { + Logger.appendLine(`Creating file and commit nodes for PR #${this.pullRequestModel.number}`, PR_TREE); + this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this._pullRequest); + this._commitsCategoryNode = new CommitsNode( + this.parent, + this._pullRequestManager, + this._pullRequest, + ); + } + this.children = [this._filesCategoryNode, this._commitsCategoryNode]; + return this.children; + } + + async getTreeItem(): Promise { + this.label = this._pullRequest.title; + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this._pullRequestManager.context, [this._pullRequest.author], 16, 16))[0]; + this.description = undefined; + if (this.parent.children?.length && this.parent.children.length > 1) { + const allSameOwner = this.parent.children.every(child => { + return child instanceof RepositoryChangesNode && child.pullRequestModel.remote.owner === this.pullRequestModel.remote.owner; + }); + if (allSameOwner) { + this.description = this._pullRequest.remote.repositoryName; + } else { + this.description = `${this._pullRequest.remote.owner}/${this._pullRequest.remote.repositoryName}`; + } + if (this.label.length > 35) { + this.tooltip = this.label; + this.label = `${this.label.substring(0, 35)}...`; + } + } + this.updateContextValue(); + return this; + } + + dispose() { + super.dispose(); + this._disposables.forEach(d => d.dispose()); + this._disposables = []; + } +} diff --git a/src/view/treeNodes/treeNode.ts b/src/view/treeNodes/treeNode.ts index 9ded97902d..556145d4f8 100644 --- a/src/view/treeNodes/treeNode.ts +++ b/src/view/treeNodes/treeNode.ts @@ -1,87 +1,88 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import Logger from '../../common/logger'; -import { dispose } from '../../common/utils'; - -export interface BaseTreeNode { - reveal(element: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; - refresh(treeNode?: TreeNode): void; - children: TreeNode[] | undefined; - view: vscode.TreeView; -} - -export type TreeNodeParent = TreeNode | BaseTreeNode; - -export const EXPANDED_QUERIES_STATE = 'expandedQueries'; - -export abstract class TreeNode implements vscode.Disposable { - protected children: TreeNode[] | undefined; - childrenDisposables: vscode.Disposable[]; - parent: TreeNodeParent; - label?: string; - accessibilityInformation?: vscode.AccessibilityInformation; - id?: string; - - constructor() { } - abstract getTreeItem(): vscode.TreeItem | Promise; - getParent(): TreeNode | undefined { - if (this.parent instanceof TreeNode) { - return this.parent; - } - } - - async reveal( - treeNode: TreeNode, - options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, - ): Promise { - try { - await this.parent.reveal(treeNode || this, options); - } catch (e) { - Logger.error(e, 'TreeNode'); - } - } - - refresh(treeNode?: TreeNode): void { - return this.parent.refresh(treeNode); - } - - async cachedChildren(): Promise { - if (this.children && this.children.length) { - return this.children; - } - return this.getChildren(); - } - - async getChildren(): Promise { - if (this.children && this.children.length) { - dispose(this.children); - this.children = []; - } - return []; - } - - updateFromCheckboxChanged(_newState: vscode.TreeItemCheckboxState): void { } - - dispose(): void { - if (this.childrenDisposables) { - dispose(this.childrenDisposables); - this.childrenDisposables = []; - } - } -} - -export class LabelOnlyNode extends TreeNode { - public readonly label: string = ''; - constructor(label: string) { - super(); - this.label = label; - } - getTreeItem(): vscode.TreeItem { - return new vscode.TreeItem(this.label); - } - -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as vscode from 'vscode'; +import Logger from '../../common/logger'; +import { dispose } from '../../common/utils'; + +export interface BaseTreeNode { + reveal(element: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; + refresh(treeNode?: TreeNode): void; + children: TreeNode[] | undefined; + view: vscode.TreeView; +} + +export type TreeNodeParent = TreeNode | BaseTreeNode; + +export const EXPANDED_QUERIES_STATE = 'expandedQueries'; + +export abstract class TreeNode implements vscode.Disposable { + protected children: TreeNode[] | undefined; + childrenDisposables: vscode.Disposable[]; + parent: TreeNodeParent; + label?: string; + accessibilityInformation?: vscode.AccessibilityInformation; + id?: string; + + constructor() { } + abstract getTreeItem(): vscode.TreeItem | Promise; + getParent(): TreeNode | undefined { + if (this.parent instanceof TreeNode) { + return this.parent; + } + } + + async reveal( + treeNode: TreeNode, + options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, + ): Promise { + try { + await this.parent.reveal(treeNode || this, options); + } catch (e) { + Logger.error(e, 'TreeNode'); + } + } + + refresh(treeNode?: TreeNode): void { + return this.parent.refresh(treeNode); + } + + async cachedChildren(): Promise { + if (this.children && this.children.length) { + return this.children; + } + return this.getChildren(); + } + + async getChildren(): Promise { + if (this.children && this.children.length) { + dispose(this.children); + this.children = []; + } + return []; + } + + updateFromCheckboxChanged(_newState: vscode.TreeItemCheckboxState): void { } + + dispose(): void { + if (this.childrenDisposables) { + dispose(this.childrenDisposables); + this.childrenDisposables = []; + } + } +} + +export class LabelOnlyNode extends TreeNode { + public readonly label: string = ''; + constructor(label: string) { + super(); + this.label = label; + } + getTreeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.label); + } + +} diff --git a/src/view/treeNodes/treeUtils.ts b/src/view/treeNodes/treeUtils.ts index aac0523229..08cd362d8e 100644 --- a/src/view/treeNodes/treeUtils.ts +++ b/src/view/treeNodes/treeUtils.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { FileChangeNode } from './fileChangeNode'; import { TreeNode } from './treeNode'; @@ -38,4 +39,4 @@ export namespace TreeUtils { prModel.markFiles(filenames, true, 'unviewed'); } } -} \ No newline at end of file +} diff --git a/src/view/treeNodes/workspaceFolderNode.ts b/src/view/treeNodes/workspaceFolderNode.ts index 2ceed1cacb..8bb1391513 100644 --- a/src/view/treeNodes/workspaceFolderNode.ts +++ b/src/view/treeNodes/workspaceFolderNode.ts @@ -1,93 +1,94 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; -import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; -import { ITelemetry } from '../../common/telemetry'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PRType } from '../../github/interface'; -import { NotificationProvider } from '../../github/notifications'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { PrsTreeModel } from '../prsTreeModel'; -import { CategoryTreeNode } from './categoryNode'; -import { EXPANDED_QUERIES_STATE, TreeNode, TreeNodeParent } from './treeNode'; - -export interface IQueryInfo { - label: string; - query: string; -} - -export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { - protected children: CategoryTreeNode[] | undefined = undefined; - public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; - - constructor( - parent: TreeNodeParent, - uri: vscode.Uri, - public readonly folderManager: FolderRepositoryManager, - private telemetry: ITelemetry, - private notificationProvider: NotificationProvider, - private context: vscode.ExtensionContext, - private readonly _prsTreeModel: PrsTreeModel - ) { - super(); - this.parent = parent; - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - this.label = path.basename(uri.fsPath); - this.id = folderManager.repository.rootUri.toString(); - } - - public async expandPullRequest(pullRequest: PullRequestModel): Promise { - if (this.children) { - for (const child of this.children) { - if (child.type === PRType.All) { - return child.expandPullRequest(pullRequest); - } - } - } - return false; - } - - private static getQueries(folderManager: FolderRepositoryManager): IQueryInfo[] { - return ( - vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE, folderManager.repository.rootUri) - .get(QUERIES) || [] - ); - } - - getTreeItem(): vscode.TreeItem { - return this; - } - - async getChildren(): Promise { - super.getChildren(); - this.children = WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this, this.notificationProvider, this.context, this._prsTreeModel); - return this.children; - } - - public static getCategoryTreeNodes( - folderManager: FolderRepositoryManager, - telemetry: ITelemetry, - parent: TreeNodeParent, - notificationProvider: NotificationProvider, - context: vscode.ExtensionContext, - prsTreeModel: PrsTreeModel - ) { - const expandedQueries = new Set(context.workspaceState.get(EXPANDED_QUERIES_STATE, [] as string[])); - - const queryCategories = WorkspaceFolderNode.getQueries(folderManager).map( - queryInfo => - new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, notificationProvider, expandedQueries, prsTreeModel, queryInfo.label, queryInfo.query), - ); - return [ - new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest, notificationProvider, expandedQueries, prsTreeModel), - ...queryCategories, - new CategoryTreeNode(parent, folderManager, telemetry, PRType.All, notificationProvider, expandedQueries, prsTreeModel), - ]; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; +import { ITelemetry } from '../../common/telemetry'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PRType } from '../../github/interface'; +import { NotificationProvider } from '../../github/notifications'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { PrsTreeModel } from '../prsTreeModel'; +import { CategoryTreeNode } from './categoryNode'; +import { EXPANDED_QUERIES_STATE, TreeNode, TreeNodeParent } from './treeNode'; + +export interface IQueryInfo { + label: string; + query: string; +} + +export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { + protected children: CategoryTreeNode[] | undefined = undefined; + public collapsibleState: vscode.TreeItemCollapsibleState; + public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; + + constructor( + parent: TreeNodeParent, + uri: vscode.Uri, + public readonly folderManager: FolderRepositoryManager, + private telemetry: ITelemetry, + private notificationProvider: NotificationProvider, + private context: vscode.ExtensionContext, + private readonly _prsTreeModel: PrsTreeModel + ) { + super(); + this.parent = parent; + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + this.label = path.basename(uri.fsPath); + this.id = folderManager.repository.rootUri.toString(); + } + + public async expandPullRequest(pullRequest: PullRequestModel): Promise { + if (this.children) { + for (const child of this.children) { + if (child.type === PRType.All) { + return child.expandPullRequest(pullRequest); + } + } + } + return false; + } + + private static getQueries(folderManager: FolderRepositoryManager): IQueryInfo[] { + return ( + vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE, folderManager.repository.rootUri) + .get(QUERIES) || [] + ); + } + + getTreeItem(): vscode.TreeItem { + return this; + } + + async getChildren(): Promise { + super.getChildren(); + this.children = WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this, this.notificationProvider, this.context, this._prsTreeModel); + return this.children; + } + + public static getCategoryTreeNodes( + folderManager: FolderRepositoryManager, + telemetry: ITelemetry, + parent: TreeNodeParent, + notificationProvider: NotificationProvider, + context: vscode.ExtensionContext, + prsTreeModel: PrsTreeModel + ) { + const expandedQueries = new Set(context.workspaceState.get(EXPANDED_QUERIES_STATE, [] as string[])); + + const queryCategories = WorkspaceFolderNode.getQueries(folderManager).map( + queryInfo => + new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, notificationProvider, expandedQueries, prsTreeModel, queryInfo.label, queryInfo.query), + ); + return [ + new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest, notificationProvider, expandedQueries, prsTreeModel), + ...queryCategories, + new CategoryTreeNode(parent, folderManager, telemetry, PRType.All, notificationProvider, expandedQueries, prsTreeModel), + ]; + } +} diff --git a/src/view/webviewViewCoordinator.ts b/src/view/webviewViewCoordinator.ts index f72786c81f..74ecf5e820 100644 --- a/src/view/webviewViewCoordinator.ts +++ b/src/view/webviewViewCoordinator.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { dispose } from '../common/utils'; import { PullRequestViewProvider } from '../github/activityBarViewProvider'; @@ -75,4 +76,4 @@ export class WebviewViewCoordinator implements vscode.Disposable { this._webviewViewProvider.show(); } } -} \ No newline at end of file +} diff --git a/webviews/activityBarView/index.ts b/webviews/activityBarView/index.ts index 20aa1f5bd3..4896424bb8 100644 --- a/webviews/activityBarView/index.ts +++ b/webviews/activityBarView/index.ts @@ -1,9 +1,10 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import '../common/common.css'; -import './index.css'; -import { main } from './app'; - -addEventListener('load', main); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; + +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); diff --git a/webviews/common/cache.ts b/webviews/common/cache.ts index 4ab516e9e0..884b74252d 100644 --- a/webviews/common/cache.ts +++ b/webviews/common/cache.ts @@ -1,28 +1,29 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { PullRequest } from '../../src/github/views'; -import { vscode } from './message'; - -export function getState(): PullRequest { - return vscode.getState(); -} - -export function setState(pullRequest: PullRequest): void { - const oldPullRequest = getState(); - - if (oldPullRequest && oldPullRequest.number && oldPullRequest.number === pullRequest.number) { - pullRequest.pendingCommentText = oldPullRequest.pendingCommentText; - } - - if (pullRequest) { - vscode.setState(pullRequest); - } -} - -export function updateState(data: Partial): void { - const pullRequest = vscode.getState(); - vscode.setState(Object.assign(pullRequest, data)); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { PullRequest } from '../../src/github/views'; +import { vscode } from './message'; + +export function getState(): PullRequest { + return vscode.getState(); +} + +export function setState(pullRequest: PullRequest): void { + const oldPullRequest = getState(); + + if (oldPullRequest && oldPullRequest.number && oldPullRequest.number === pullRequest.number) { + pullRequest.pendingCommentText = oldPullRequest.pendingCommentText; + } + + if (pullRequest) { + vscode.setState(pullRequest); + } +} + +export function updateState(data: Partial): void { + const pullRequest = vscode.getState(); + vscode.setState(Object.assign(pullRequest, data)); +} diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index b505c569b2..2a8c9be579 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { createContext } from 'react'; import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; import { getMessageHandler, MessageHandler, vscode } from './message'; diff --git a/webviews/common/events.ts b/webviews/common/events.ts index 77adc7ba9b..ef8ae9d23d 100644 --- a/webviews/common/events.ts +++ b/webviews/common/events.ts @@ -1,8 +1,9 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { EventEmitter } from 'events'; - -export default new EventEmitter(); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { EventEmitter } from 'events'; + +export default new EventEmitter(); diff --git a/webviews/common/hooks.ts b/webviews/common/hooks.ts index d62d014932..290ccf7379 100644 --- a/webviews/common/hooks.ts +++ b/webviews/common/hooks.ts @@ -1,23 +1,24 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; - -/** - * useState, but track the value of a prop. - * - * When the prop value changes, the tracked state will be updated to match. - * - * @param prop S the prop to track - */ -export function useStateProp(prop: S): [S, Dispatch>] { - const [state, setState] = useState(prop); - useEffect(() => { - if (state !== prop) { - setState(prop); - } - }, [prop]); - return [state, setState]; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +/** + * useState, but track the value of a prop. + * + * When the prop value changes, the tracked state will be updated to match. + * + * @param prop S the prop to track + */ +export function useStateProp(prop: S): [S, Dispatch>] { + const [state, setState] = useState(prop); + useEffect(() => { + if (state !== prop) { + setState(prop); + } + }, [prop]); + return [state, setState]; +} diff --git a/webviews/common/message.ts b/webviews/common/message.ts index 5c5640ea34..e52cd316ac 100644 --- a/webviews/common/message.ts +++ b/webviews/common/message.ts @@ -1,74 +1,75 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -interface IRequestMessage { - req: string; - command: string; - args: T; -} - -interface IReplyMessage { - seq: string; - err: any; - res: any; -} - -declare let acquireVsCodeApi: any; -export const vscode = acquireVsCodeApi(); - -export class MessageHandler { - private _commandHandler: ((message: any) => void) | null; - private lastSentReq: number; - private pendingReplies: any; - constructor(commandHandler: any) { - this._commandHandler = commandHandler; - this.lastSentReq = 0; - this.pendingReplies = Object.create(null); - window.addEventListener('message', this.handleMessage.bind(this) as (this: Window, ev: MessageEvent) => any); - } - - public registerCommandHandler(commandHandler: (message: any) => void) { - this._commandHandler = commandHandler; - } - - public async postMessage(message: any): Promise { - const req = String(++this.lastSentReq); - return new Promise((resolve, reject) => { - this.pendingReplies[req] = { - resolve: resolve, - reject: reject, - }; - message = Object.assign(message, { - req: req, - }); - vscode.postMessage(message as IRequestMessage); - }); - } - - // handle message should resolve promises - private handleMessage(event: any) { - const message: IReplyMessage = event.data; // The json data that the extension sent - if (message.seq) { - // this is a reply - const pendingReply = this.pendingReplies[message.seq]; - if (pendingReply) { - if (message.err) { - pendingReply.reject(message.err); - } else { - pendingReply.resolve(message.res); - } - return; - } - } - - if (this._commandHandler) { - this._commandHandler(message.res); - } - } -} - -export function getMessageHandler(handler: ((message: any) => void) | null) { - return new MessageHandler(handler); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +interface IRequestMessage { + req: string; + command: string; + args: T; +} + +interface IReplyMessage { + seq: string; + err: any; + res: any; +} + +declare let acquireVsCodeApi: any; +export const vscode = acquireVsCodeApi(); + +export class MessageHandler { + private _commandHandler: ((message: any) => void) | null; + private lastSentReq: number; + private pendingReplies: any; + constructor(commandHandler: any) { + this._commandHandler = commandHandler; + this.lastSentReq = 0; + this.pendingReplies = Object.create(null); + window.addEventListener('message', this.handleMessage.bind(this) as (this: Window, ev: MessageEvent) => any); + } + + public registerCommandHandler(commandHandler: (message: any) => void) { + this._commandHandler = commandHandler; + } + + public async postMessage(message: any): Promise { + const req = String(++this.lastSentReq); + return new Promise((resolve, reject) => { + this.pendingReplies[req] = { + resolve: resolve, + reject: reject, + }; + message = Object.assign(message, { + req: req, + }); + vscode.postMessage(message as IRequestMessage); + }); + } + + // handle message should resolve promises + private handleMessage(event: any) { + const message: IReplyMessage = event.data; // The json data that the extension sent + if (message.seq) { + // this is a reply + const pendingReply = this.pendingReplies[message.seq]; + if (pendingReply) { + if (message.err) { + pendingReply.reject(message.err); + } else { + pendingReply.resolve(message.res); + } + return; + } + } + + if (this._commandHandler) { + this._commandHandler(message.res); + } + } +} + +export function getMessageHandler(handler: ((message: any) => void) | null) { + return new MessageHandler(handler); +} diff --git a/webviews/createPullRequestViewNew/index.ts b/webviews/createPullRequestViewNew/index.ts index 20aa1f5bd3..4896424bb8 100644 --- a/webviews/createPullRequestViewNew/index.ts +++ b/webviews/createPullRequestViewNew/index.ts @@ -1,9 +1,10 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import '../common/common.css'; -import './index.css'; -import { main } from './app'; - -addEventListener('load', main); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; + +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); diff --git a/webviews/editorWebview/index.ts b/webviews/editorWebview/index.ts index 20aa1f5bd3..4896424bb8 100644 --- a/webviews/editorWebview/index.ts +++ b/webviews/editorWebview/index.ts @@ -1,9 +1,10 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import '../common/common.css'; -import './index.css'; -import { main } from './app'; - -addEventListener('load', main); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; + +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); diff --git a/webviews/editorWebview/test/builder/account.ts b/webviews/editorWebview/test/builder/account.ts index 9846a99e0c..83079fbafc 100644 --- a/webviews/editorWebview/test/builder/account.ts +++ b/webviews/editorWebview/test/builder/account.ts @@ -1,15 +1,16 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IAccount } from '../../../../src/github/interface'; -import { createBuilderClass } from '../../../../src/test/builders/base'; - -export const AccountBuilder = createBuilderClass()({ - login: { default: 'me' }, - name: { default: 'Myself' }, - avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, - url: { default: 'https://github.com/me' }, - id: { default: '123' } -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { IAccount } from '../../../../src/github/interface'; +import { createBuilderClass } from '../../../../src/test/builders/base'; + +export const AccountBuilder = createBuilderClass()({ + login: { default: 'me' }, + name: { default: 'Myself' }, + avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, + url: { default: 'https://github.com/me' }, + id: { default: '123' } +}); diff --git a/webviews/editorWebview/test/builder/pullRequest.ts b/webviews/editorWebview/test/builder/pullRequest.ts index aae8212411..a5ecab006c 100644 --- a/webviews/editorWebview/test/builder/pullRequest.ts +++ b/webviews/editorWebview/test/builder/pullRequest.ts @@ -1,58 +1,59 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { GithubItemStateEnum, PullRequestMergeability } from '../../../../src/github/interface'; -import { PullRequest } from '../../../../src/github/views'; -import { createBuilderClass } from '../../../../src/test/builders/base'; -import { CombinedStatusBuilder } from '../../../../src/test/builders/rest/combinedStatusBuilder'; - -import { AccountBuilder } from './account'; - -export const PullRequestBuilder = createBuilderClass()({ - number: { default: 1234 }, - title: { default: 'the default title' }, - titleHTML: { default: 'the default title' }, - url: { default: 'https://github.com/owner/name/pulls/1234' }, - createdAt: { default: '2019-01-01T10:00:00Z' }, - body: { default: 'the *default* body' }, - bodyHTML: { default: 'the default body' }, - author: { linked: AccountBuilder }, - state: { default: GithubItemStateEnum.Open }, - events: { default: [] }, - isCurrentlyCheckedOut: { default: true }, - isRemoteBaseDeleted: { default: false }, - base: { default: 'main' }, - isRemoteHeadDeleted: { default: false }, - isLocalHeadDeleted: { default: false }, - head: { default: 'my-fork:my-branch' }, - labels: { default: [] }, - commitsCount: { default: 10 }, - repositoryDefaultBranch: { default: 'main' }, - canEdit: { default: true }, - hasWritePermission: { default: true }, - pendingCommentText: { default: undefined }, - pendingCommentDrafts: { default: undefined }, - status: { linked: CombinedStatusBuilder }, - reviewRequirement: { default: null }, - mergeable: { default: PullRequestMergeability.Mergeable }, - defaultMergeMethod: { default: 'merge' }, - mergeMethodsAvailability: { default: { merge: true, squash: true, rebase: true } }, - allowAutoMerge: { default: false }, - mergeQueueEntry: { default: undefined }, - mergeQueueMethod: { default: undefined }, - reviewers: { default: [] }, - isDraft: { default: false }, - isIssue: { default: false }, - assignees: { default: [] }, - projectItems: { default: undefined }, - milestone: { default: undefined }, - continueOnGitHub: { default: false }, - currentUserReviewState: { default: 'REQUESTED' }, - isDarkTheme: { default: true }, - isEnterprise: { default: false }, - hasReviewDraft: { default: false }, - busy: { default: undefined }, - lastReviewType: { default: undefined }, -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { GithubItemStateEnum, PullRequestMergeability } from '../../../../src/github/interface'; +import { PullRequest } from '../../../../src/github/views'; +import { createBuilderClass } from '../../../../src/test/builders/base'; +import { CombinedStatusBuilder } from '../../../../src/test/builders/rest/combinedStatusBuilder'; + +import { AccountBuilder } from './account'; + +export const PullRequestBuilder = createBuilderClass()({ + number: { default: 1234 }, + title: { default: 'the default title' }, + titleHTML: { default: 'the default title' }, + url: { default: 'https://github.com/owner/name/pulls/1234' }, + createdAt: { default: '2019-01-01T10:00:00Z' }, + body: { default: 'the *default* body' }, + bodyHTML: { default: 'the default body' }, + author: { linked: AccountBuilder }, + state: { default: GithubItemStateEnum.Open }, + events: { default: [] }, + isCurrentlyCheckedOut: { default: true }, + isRemoteBaseDeleted: { default: false }, + base: { default: 'main' }, + isRemoteHeadDeleted: { default: false }, + isLocalHeadDeleted: { default: false }, + head: { default: 'my-fork:my-branch' }, + labels: { default: [] }, + commitsCount: { default: 10 }, + repositoryDefaultBranch: { default: 'main' }, + canEdit: { default: true }, + hasWritePermission: { default: true }, + pendingCommentText: { default: undefined }, + pendingCommentDrafts: { default: undefined }, + status: { linked: CombinedStatusBuilder }, + reviewRequirement: { default: null }, + mergeable: { default: PullRequestMergeability.Mergeable }, + defaultMergeMethod: { default: 'merge' }, + mergeMethodsAvailability: { default: { merge: true, squash: true, rebase: true } }, + allowAutoMerge: { default: false }, + mergeQueueEntry: { default: undefined }, + mergeQueueMethod: { default: undefined }, + reviewers: { default: [] }, + isDraft: { default: false }, + isIssue: { default: false }, + assignees: { default: [] }, + projectItems: { default: undefined }, + milestone: { default: undefined }, + continueOnGitHub: { default: false }, + currentUserReviewState: { default: 'REQUESTED' }, + isDarkTheme: { default: true }, + isEnterprise: { default: false }, + hasReviewDraft: { default: false }, + busy: { default: undefined }, + lastReviewType: { default: undefined }, +});