-
Notifications
You must be signed in to change notification settings - Fork 41.9k
Add TypeScript 7 context support #331798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add TypeScript 7 context support #331798
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
c0633c6
First cut of TS7 context
dbaeumer 2d7b436
WIP
dbaeumer d94468d
Bind events correctly
dbaeumer 02e50a1
Merge branch 'main' into dbaeumer/ts7context
dbaeumer cbac046
First TS7 implementation.
dbaeumer 95a300a
First cut of TS7 context providers
dbaeumer b0f9c13
Minor improvements
dbaeumer 471be7b
Merge branch 'main' into dbaeumer/ts7context
dbaeumer 69a660e
Merge branch 'main' into dbaeumer/ts7context
dbaeumer cdaa25e
Bug fixes for TS7 context provider
dbaeumer 9e69bd2
Merge branch 'main' into dbaeumer/ts7context
dbaeumer 340a6c2
Add setting to enable TS7 language context
dbaeumer c2d04aa
Use getSymbolsInScope
dbaeumer 828f1d3
Implement nes rename on TS7 Api
dbaeumer 5a56492
First cut of code review
dbaeumer 9b42e78
Add the TS7 enablement settings
dbaeumer bf3a23a
Merge branch 'main' into dbaeumer/ts7context
dbaeumer f4d1665
Some bug fixes
dbaeumer 8507d55
Some final fixes
dbaeumer 6851965
Make the shared API work
dbaeumer f2c0965
Reject nes rename on symbols from libs
dbaeumer 30cce93
Consider typescriptteam.vscode-typescript extension id
dbaeumer e063c68
Move tests to test folders
dbaeumer e2c6f0f
Fix registration code.
dbaeumer b1ba03e
Handle cancellation correctly
dbaeumer 21a99b3
Fix test case and awaits
dbaeumer 648cbf6
Make inflight request fail save
dbaeumer 9989efa
Implement correct dispose
dbaeumer c53f651
Merge branch 'main' into dbaeumer/ts7context
dbaeumer e4cb3c9
Fix failing test
dbaeumer 4104431
Potential fix for pull request finding
dbaeumer fc07285
Use 7.0.2 again
dbaeumer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1,627 changes: 79 additions & 1,548 deletions
1,627
extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts
Large diffs are not rendered by default.
Oops, something went wrong.
265 changes: 113 additions & 152 deletions
265
extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts
Large diffs are not rendered by default.
Oops, something went wrong.
423 changes: 423 additions & 0 deletions
423
extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts
Large diffs are not rendered by default.
Oops, something went wrong.
44 changes: 44 additions & 0 deletions
44
extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /*--------------------------------------------------------------------------------------------- | ||
| * Copyright (c) Microsoft Corporation. All rights reserved. | ||
| * Licensed under the MIT License. See License.txt in the project root for license information. | ||
| *--------------------------------------------------------------------------------------------*/ | ||
|
|
||
| import assert from 'node:assert'; | ||
|
|
||
| import type * as vscode from 'vscode'; | ||
| import { suite, test, vi } from 'vitest'; | ||
|
|
||
| vi.mock('vscode', () => ({})); | ||
|
|
||
| import { TypeScript } from '../tsService'; | ||
|
|
||
| suite('TypeScript service', () => { | ||
| test('prefers the current TS7 extension and falls back to the legacy extension', () => { | ||
| const currentExtensionId = 'typescriptteam.vscode-typescript'; | ||
| const legacyExtensionId = 'typescriptteam.native-preview'; | ||
| const scenarios = [ | ||
| [currentExtensionId, legacyExtensionId], | ||
| [legacyExtensionId], | ||
| [], | ||
| ]; | ||
|
|
||
| const actual = scenarios.map(extensionIds => { | ||
| const available = new Map<string, vscode.Extension<unknown>>(); | ||
| for (const extensionId of extensionIds) { | ||
| available.set(extensionId, { id: extensionId } as vscode.Extension<unknown>); | ||
| } | ||
| const lookups: string[] = []; | ||
| const extension = TypeScript.getVersion7Extension(extensionId => { | ||
| lookups.push(extensionId); | ||
| return available.get(extensionId); | ||
| }); | ||
| return { selected: extension?.id, lookups }; | ||
| }); | ||
|
|
||
| assert.deepStrictEqual(actual, [ | ||
| { selected: currentExtensionId, lookups: [currentExtensionId] }, | ||
| { selected: legacyExtensionId, lookups: [currentExtensionId, legacyExtensionId] }, | ||
| { selected: undefined, lookups: [currentExtensionId, legacyExtensionId] }, | ||
| ]); | ||
| }); | ||
| }); |
299 changes: 299 additions & 0 deletions
299
extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| /*--------------------------------------------------------------------------------------------- | ||
| * Copyright (c) Microsoft Corporation. All rights reserved. | ||
| * Licensed under the MIT License. See License.txt in the project root for license information. | ||
| *--------------------------------------------------------------------------------------------*/ | ||
|
|
||
| import type { API, Project, Snapshot, DocumentIdentifier } from '@typescript/native/unstable/async'; | ||
| import { | ||
| SyntaxKind, | ||
| isArrowFunction, | ||
| isClassDeclaration, | ||
| isConstructorDeclaration, | ||
| isFunctionDeclaration, | ||
| isFunctionExpression, | ||
| isGetAccessorDeclaration, | ||
| isMethodDeclaration, | ||
| isModuleDeclaration, | ||
| isSetAccessorDeclaration, | ||
| isSourceFile, | ||
| type Node, | ||
| type SourceFile, | ||
| } from '@typescript/native/unstable/ast'; | ||
| import * as protocol from '../../common/serverProtocol'; | ||
| import { CompilerOptionsRunnable } from './baseContextProviders'; | ||
| import { ClassContextProvider } from './classContextProvider'; | ||
| import { ContextProvider, ContextRunnableCollector, type ComputeContextSession, type ContextProviderFactory, type ContextResult, type ContextRunnable, type ProviderComputeContext, type RequestContext } from './contextProvider'; | ||
| import { FunctionContextProvider } from './functionContextProvider'; | ||
| import { AccessorProvider, ConstructorContextProvider, MethodContextProvider } from './methodContextProvider'; | ||
| import { ModuleContextProvider } from './moduleContextProvider'; | ||
| import { PrepareNesRenameResult, validateNesRename } from './nesRenameValidator'; | ||
| import { SourceFileContextProvider } from './sourceFileContextProvider'; | ||
| import { RecoverableError } from './types'; | ||
| import tss, { Symbols, type CancellationTokenWithTimer } from './typescripts'; | ||
|
|
||
| class ProviderComputeContextImpl implements ProviderComputeContext { | ||
| private firstCallableProvider: ContextProvider | undefined; | ||
|
|
||
| public update(contextProvider: ContextProvider): ContextProvider { | ||
| if (this.firstCallableProvider === undefined && contextProvider.isCallableProvider === true) { | ||
| this.firstCallableProvider = contextProvider; | ||
| } | ||
| return contextProvider; | ||
| } | ||
|
|
||
| public isFirstCallableProvider(contextProvider: ContextProvider): boolean { | ||
| return this.firstCallableProvider === contextProvider; | ||
| } | ||
| } | ||
|
|
||
| class ContextProviders { | ||
| private static readonly Factories = new Map<SyntaxKind, ContextProviderFactory>([ | ||
| [SyntaxKind.SourceFile, (_node, tokenInfo, computeContext) => new SourceFileContextProvider(tokenInfo, computeContext)], | ||
| [SyntaxKind.FunctionDeclaration, (node, tokenInfo, computeContext) => isFunctionDeclaration(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.ArrowFunction, (node, tokenInfo, computeContext) => isArrowFunction(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.FunctionExpression, (node, tokenInfo, computeContext) => isFunctionExpression(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.GetAccessor, (node, tokenInfo, computeContext) => isGetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.SetAccessor, (node, tokenInfo, computeContext) => isSetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.ClassDeclaration, (node, tokenInfo) => isClassDeclaration(node) ? ClassContextProvider.create(node, tokenInfo) : undefined], | ||
| [SyntaxKind.Constructor, (node, tokenInfo, computeContext) => isConstructorDeclaration(node) ? new ConstructorContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.MethodDeclaration, (node, tokenInfo, computeContext) => isMethodDeclaration(node) ? new MethodContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| [SyntaxKind.ModuleDeclaration, (node, tokenInfo, computeContext) => isModuleDeclaration(node) ? new ModuleContextProvider(node, tokenInfo, computeContext) : undefined], | ||
| ]); | ||
|
|
||
| private readonly tokenInfo: tss.TokenInfo; | ||
| private readonly computeInfo: ProviderComputeContextImpl = new ProviderComputeContextImpl(); | ||
|
|
||
| constructor(tokenInfo: tss.TokenInfo) { | ||
| this.tokenInfo = tokenInfo; | ||
| } | ||
|
|
||
| public async execute(result: ContextResult, session: ComputeContextSession, project: Project, token: CancellationTokenWithTimer): Promise<void> { | ||
| const collector = await this.getContextRunnables(session, project, result.context, token); | ||
| result.addPath(tss.StableSyntaxKinds.getPath(this.tokenInfo.touching ?? this.tokenInfo.token)); | ||
| for (const runnable of collector.entries()) { | ||
| runnable.initialize(result); | ||
| } | ||
| await this.executeRunnables(collector.getPrimaryRunnables(), result, token); | ||
| await this.executeRunnables(collector.getSecondaryRunnables(), result, token); | ||
| await this.executeRunnables(collector.getTertiaryRunnables(), result, token); | ||
| result.done(); | ||
| } | ||
|
|
||
| private async executeRunnables(runnables: ContextRunnable[], result: ContextResult, token: CancellationTokenWithTimer): Promise<void> { | ||
| for (const runnable of runnables) { | ||
| token.throwIfCancellationRequested(); | ||
| try { | ||
| await runnable.compute(token); | ||
| } catch (error) { | ||
| if (error instanceof RecoverableError) { | ||
| result.addErrorData(error); | ||
| } else { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async getContextRunnables(session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise<ContextRunnableCollector> { | ||
| const result = new ContextRunnableCollector(context.clientSideRunnableResults); | ||
| result.addPrimary(new CompilerOptionsRunnable(session, project, context, this.tokenInfo.token.getSourceFile())); | ||
| for (const provider of this.computeProviders()) { | ||
| await provider.provide(result, session, project, context, token); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| private computeProviders(): ContextProvider[] { | ||
| const result: ContextProvider[] = []; | ||
| let token: Node | undefined = this.tokenInfo.touching; | ||
| if (token === undefined) { | ||
| token = this.tokenInfo.token.kind === SyntaxKind.EndOfFile ? this.tokenInfo.previous : this.tokenInfo.token; | ||
| } | ||
| if (token === undefined || token.kind === SyntaxKind.EndOfFile) { | ||
| return result; | ||
| } | ||
| let current: Node | undefined = token; | ||
| while (current !== undefined) { | ||
| const factory = ContextProviders.Factories.get(current.kind); | ||
| const provider = factory?.(current, this.tokenInfo, this.computeInfo); | ||
| if (provider !== undefined) { | ||
| result.push(this.computeInfo.update(provider)); | ||
| } | ||
| if (isSourceFile(current)) { | ||
| break; | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| export async function computeContext(result: ContextResult, session: ComputeContextSession, project: Project, document: SourceFile, position: number, token: CancellationTokenWithTimer): Promise<void> { | ||
| const sourceFile = await project.program.getSourceFile(document.fileName); | ||
| if (sourceFile === undefined) { | ||
| result.addErrorData(new RecoverableError('No source file found for document', RecoverableError.NoSourceFile)); | ||
| return; | ||
| } | ||
| const tokenInfo = tss.getRelevantTokens(sourceFile, position); | ||
| await new ContextProviders(tokenInfo).execute(result, session, project, token); | ||
| } | ||
|
|
||
| export async function prepareNesRename<FromLSP extends boolean>(result: PrepareNesRenameResult, api: API<FromLSP>, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise<void> { | ||
| if (typeof oldName !== 'string' || oldName.length === 0) { | ||
| result.setCanRename(protocol.RenameKind.no, 'No old name provided'); | ||
| return; | ||
| } | ||
| if (typeof newName !== 'string' || newName.length === 0) { | ||
| result.setCanRename(protocol.RenameKind.no, 'No new name provided'); | ||
| return; | ||
| } | ||
|
|
||
| const state = await doPrepareNesRename(result, project, document, position, oldName, newName, token); | ||
| if (state !== PrepareState.unavailable || lastSymbolRename === undefined) { | ||
| return; | ||
| } | ||
|
|
||
| const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); | ||
| await runWithTemporaryFileUpdate<FromLSP>(api, snapshot, document.fileName, oldText, async updatedSnapshot => { | ||
| const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); | ||
| const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); | ||
| if (updatedProject === undefined || updatedSourceFile === undefined) { | ||
| result.setCanRename(protocol.RenameKind.no, 'No source file found for document'); | ||
| return; | ||
| } | ||
| const updatedState = await doPrepareNesRename(result, updatedProject, updatedSourceFile, oldPosition, oldName, newName, token); | ||
| if (updatedState === PrepareState.prepared && (result.getCanRename() === protocol.RenameKind.maybe || result.getCanRename() === protocol.RenameKind.yes)) { | ||
| result.setOnOldState(true); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| export async function nesRename<FromLSP extends boolean>(api: API<FromLSP>, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise<protocol.RenameGroup[]> { | ||
| if (oldName === undefined || newName === undefined || lastSymbolRename === undefined) { | ||
| return []; | ||
| } | ||
|
|
||
| const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); | ||
| const groups = new Map<string, protocol.RenameGroup>(); | ||
| const seen = new Set<string>(); | ||
| await runWithTemporaryFileUpdate<FromLSP>(api, snapshot, document.fileName, oldText, async updatedSnapshot => { | ||
| const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); | ||
| const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); | ||
| if (updatedProject === undefined || updatedSourceFile === undefined) { | ||
| return; | ||
| } | ||
| const renameTarget = getRenameTarget(updatedSourceFile, oldPosition, oldName); | ||
| if (renameTarget.node.getText(updatedSourceFile) !== oldName) { | ||
| return; | ||
| } | ||
| const symbols = new Symbols(updatedProject, token); | ||
| const referencedSymbols = await updatedProject.checker.getReferencedSymbolsForNode(renameTarget.node, renameTarget.position); | ||
| for (const referencedSymbol of referencedSymbols) { | ||
| const definition = await referencedSymbol.definition.resolve(updatedProject); | ||
| if (definition === undefined || await symbols.isSourceFileFromLibrary(definition.getSourceFile())) { | ||
| return; | ||
| } | ||
| } | ||
| for (const referencedSymbol of referencedSymbols) { | ||
| for (const reference of referencedSymbol.references) { | ||
| token.throwIfCancellationRequested(); | ||
| const node = await reference.resolve(updatedProject); | ||
| if (node === undefined) { | ||
| continue; | ||
| } | ||
| const sourceFile = node.getSourceFile(); | ||
| if (await symbols.isSourceFileFromLibrary(sourceFile)) { | ||
| continue; | ||
| } | ||
| const startPosition = node.getStart(sourceFile); | ||
|
dbaeumer marked this conversation as resolved.
|
||
| const endPosition = node.getEnd(); | ||
| const key = `${sourceFile.path}:${startPosition}:${endPosition}`; | ||
| if (seen.has(key)) { | ||
| continue; | ||
| } | ||
| seen.add(key); | ||
| const start = sourceFile.getLineAndCharacterOfPosition(startPosition); | ||
| const end = sourceFile.getLineAndCharacterOfPosition(endPosition); | ||
| const delta = newName.length - oldName.length; | ||
| if ( | ||
| sourceFile.fileName === document.fileName && | ||
| start.line === lastSymbolRename.start.line && start.character === lastSymbolRename.start.character && | ||
| end.line === lastSymbolRename.end.line && end.character === lastSymbolRename.end.character - delta | ||
| ) { | ||
| continue; | ||
| } | ||
| let group = groups.get(sourceFile.fileName); | ||
| if (group === undefined) { | ||
| group = { file: sourceFile.fileName, changes: [] }; | ||
| groups.set(sourceFile.fileName, group); | ||
| } | ||
| group.changes.push({ | ||
| range: { | ||
| start: { line: start.line, character: start.character }, | ||
| end: { line: end.line, character: end.character }, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| }); | ||
| return Array.from(groups.values()); | ||
| } | ||
|
|
||
| function runWithTemporaryFileUpdate<FromLSP extends boolean>(api: API<FromLSP>, baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise<void>): Promise<void> { | ||
| interface ApiWithTemporaryFileUpdate { | ||
| runWithTemporaryFileUpdate(baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise<void>): Promise<void>; | ||
| } | ||
| if (typeof (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate === 'function') { | ||
| return (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate(baseSnapshot, file, newText, cb); | ||
| } | ||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| const enum PrepareState { | ||
| prepared, | ||
| unavailable, | ||
| mismatch, | ||
| } | ||
|
|
||
| async function doPrepareNesRename(result: PrepareNesRenameResult, project: Project, sourceFile: SourceFile, position: number, oldName: string, newName: string, token: CancellationTokenWithTimer): Promise<PrepareState> { | ||
| const renameTarget = getRenameTarget(sourceFile, position, oldName); | ||
| const tokenText = renameTarget.node.getText(sourceFile); | ||
| if (tokenText !== oldName) { | ||
| result.setCanRename(protocol.RenameKind.no, `Old name '${oldName}' does not match symbol name '${tokenText}'`); | ||
| return PrepareState.mismatch; | ||
| } | ||
| token.throwIfCancellationRequested(); | ||
| if (await project.checker.getSymbolAtLocation(renameTarget.node) === undefined) { | ||
| result.setCanRename(protocol.RenameKind.no, 'No symbol found at location'); | ||
| return PrepareState.unavailable; | ||
| } | ||
| result.setCanRename(protocol.RenameKind.maybe, oldName); | ||
| await validateNesRename(result, project, renameTarget.node, oldName, newName, token); | ||
| return PrepareState.prepared; | ||
| } | ||
|
|
||
| function getRenameTarget(sourceFile: SourceFile, position: number, oldName: string): { node: Node; position: number } { | ||
| const token = tss.getRelevantTokens(sourceFile, position).token; | ||
| if (token.getText(sourceFile) === oldName) { | ||
| return { node: token, position }; | ||
| } | ||
| let current: Node | undefined = token.parent; | ||
| while (current !== undefined && !isSourceFile(current)) { | ||
| if (isFunctionDeclaration(current) && current.name?.getText(sourceFile) === oldName) { | ||
| return { node: current.name, position: current.name.getStart(sourceFile) }; | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return { node: token, position }; | ||
| } | ||
|
|
||
| async function getUpdatedProject(snapshot: Snapshot, project: Project, fileName: string): Promise<Project | undefined> { | ||
| return snapshot.getProject(project.configFileName) ?? await snapshot.getDefaultProjectForFile(fileName); | ||
| } | ||
|
|
||
| function getOldText(sourceFile: SourceFile, position: number, oldName: string, newName: string, lastSymbolRename: protocol.Range): [string, number] { | ||
| const startPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.start.line, lastSymbolRename.start.character); | ||
| const endPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.end.line, lastSymbolRename.end.character); | ||
| const oldText = sourceFile.text.substring(0, startPosition) + oldName + sourceFile.text.substring(endPosition); | ||
| return [oldText, position < startPosition ? position : position - (newName.length - oldName.length)]; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.