Merge pull request #1440 from akhera99/dartlab_pipeline_script
Adds a command to run the DartLab pipeline
This commit is contained in:
Коммит
850c7ef23a
|
@ -0,0 +1,81 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// See the License.txt file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Invocation;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.RoslynTools.Utilities;
|
||||
using Microsoft.RoslynTools.VS;
|
||||
|
||||
namespace Microsoft.RoslynTools.Commands;
|
||||
|
||||
internal static class DartTestCommand
|
||||
{
|
||||
private static readonly string[] s_allProductNames = VSBranchInfo.AllProducts.Where(p => p.DartLabPipelineName != null).Select(p => p.Name.ToLower()).ToArray();
|
||||
|
||||
private static readonly DartTestCommandDefaultHandler s_dartTestCommandHandler = new();
|
||||
private static readonly Option<int> prNumber = new(["--prNumber", "-n"], "PR number") { IsRequired = true };
|
||||
private static readonly Option<string> sha = new(["--sha", "-s"], "Relevant SHA") { IsRequired = false };
|
||||
private static readonly Option<string> productOption = new Option<string>(new[] { "--product", "-p" }, () => "roslyn", "Which product to get info for").FromAmong(s_allProductNames);
|
||||
|
||||
public static Symbol GetCommand()
|
||||
{
|
||||
var command = new Command("dart-test",
|
||||
@"Runs the dartlab pipeline for a given PR number and SHA.
|
||||
It works by cloning the PR into the internal mirror and then running the dartlab pipeline from it.")
|
||||
{
|
||||
prNumber,
|
||||
sha,
|
||||
productOption,
|
||||
CommonOptions.GitHubTokenOption,
|
||||
CommonOptions.DevDivAzDOTokenOption,
|
||||
CommonOptions.DncEngAzDOTokenOption,
|
||||
CommonOptions.IsCIOption,
|
||||
};
|
||||
|
||||
command.Handler = s_dartTestCommandHandler;
|
||||
return command;
|
||||
}
|
||||
|
||||
private class DartTestCommandDefaultHandler : ICommandHandler
|
||||
{
|
||||
public async Task<int> InvokeAsync(InvocationContext context)
|
||||
{
|
||||
var logger = context.SetupLogging();
|
||||
var settings = context.ParseResult.LoadSettings(logger);
|
||||
|
||||
var isMissingAzDOToken = string.IsNullOrEmpty(settings.DevDivAzureDevOpsToken) || string.IsNullOrEmpty(settings.DncEngAzureDevOpsToken);
|
||||
if (string.IsNullOrEmpty(settings.GitHubToken) ||
|
||||
(settings.IsCI && isMissingAzDOToken))
|
||||
{
|
||||
logger.LogError("Missing authentication token.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
var pr = context.ParseResult.GetValueForOption(prNumber);
|
||||
var shaFromPR = context.ParseResult.GetValueForOption(sha);
|
||||
|
||||
if (shaFromPR is null)
|
||||
{
|
||||
logger.LogInformation("Using most recent SHA");
|
||||
}
|
||||
|
||||
var product = context.ParseResult.GetValueForOption(productOption)!;
|
||||
|
||||
using var remoteConnections = new RemoteConnections(settings);
|
||||
|
||||
return await DartTest.DartTest.RunDartPipeline(
|
||||
product,
|
||||
remoteConnections,
|
||||
logger,
|
||||
pr,
|
||||
shaFromPR).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
using System.CommandLine;
|
||||
using System.CommandLine.Invocation;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.RoslynTools.PRTagger;
|
||||
using Microsoft.RoslynTools.Utilities;
|
||||
|
||||
namespace Microsoft.RoslynTools.Commands;
|
||||
|
||||
|
@ -45,9 +45,9 @@ The checking build list is created:
|
|||
var logger = context.SetupLogging();
|
||||
var settings = context.ParseResult.LoadSettings(logger);
|
||||
|
||||
var isMissingAzDOToken = string.IsNullOrEmpty(settings.DevDivAzureDevOpsToken) || string.IsNullOrEmpty(settings.DncEngAzureDevOpsToken);
|
||||
if (string.IsNullOrEmpty(settings.GitHubToken) ||
|
||||
string.IsNullOrEmpty(settings.DevDivAzureDevOpsToken) ||
|
||||
string.IsNullOrEmpty(settings.DncEngAzureDevOpsToken))
|
||||
(settings.IsCI && isMissingAzDOToken))
|
||||
{
|
||||
logger.LogError("Missing authentication token.");
|
||||
return -1;
|
||||
|
|
|
@ -20,6 +20,7 @@ internal static class RootRoslynCommand
|
|||
NuGetPublishCommand.GetCommand(),
|
||||
CreateReleaseTagsCommand.GetCommand(),
|
||||
VSBranchInfoCommand.GetCommand(),
|
||||
DartTestCommand.GetCommand(),
|
||||
};
|
||||
command.Name = "roslyn-tools";
|
||||
command.Description = "The command line tool for performing infrastructure tasks.";
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// See the License.txt file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using LibGit2Sharp;
|
||||
using Microsoft.Azure.Pipelines.WebApi;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.RoslynTools.Products;
|
||||
using Microsoft.RoslynTools.Utilities;
|
||||
using Microsoft.RoslynTools.VS;
|
||||
using Microsoft.TeamFoundation.Common;
|
||||
using Microsoft.TeamFoundation.SourceControl.WebApi;
|
||||
using static System.Net.WebRequestMethods;
|
||||
|
||||
namespace Microsoft.RoslynTools.DartTest;
|
||||
|
||||
internal static class DartTest
|
||||
{
|
||||
public static async Task<int> RunDartPipeline(
|
||||
string productName,
|
||||
RemoteConnections remoteConnections,
|
||||
ILogger logger,
|
||||
int prNumber,
|
||||
string? sha)
|
||||
{
|
||||
string? targetDirectory = null;
|
||||
try
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
var azureBranchName = $"dart-test/{prNumber}";
|
||||
var product = VSBranchInfo.AllProducts.Single(p => p.Name.Equals(productName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// If the user doesn't pass the SHA, retrieve the most recent from the PR.
|
||||
if (sha is null)
|
||||
{
|
||||
sha = await GetLatestShaFromPullRequestAsync(product, remoteConnections.GitHubClient, prNumber, logger, cancellationToken).ConfigureAwait(false);
|
||||
if (sha is null)
|
||||
{
|
||||
logger.LogError("Could not find a SHA for the given PR number.");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
targetDirectory = CreateDirectory(prNumber);
|
||||
await PushPRToInternalAsync(product, prNumber, azureBranchName, logger, sha, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
var repositoryParams = new Dictionary<string, RepositoryResourceParameters>
|
||||
{
|
||||
{
|
||||
"self", new RepositoryResourceParameters
|
||||
{
|
||||
RefName = $"refs/heads/{azureBranchName}",
|
||||
Version = sha
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var runPipelineParameters = new RunPipelineParameters
|
||||
{
|
||||
Resources = new RunResourcesParameters
|
||||
{
|
||||
|
||||
},
|
||||
TemplateParameters = new Dictionary<string, string> { { "prNumber", prNumber.ToString() }, { "sha", sha } }
|
||||
};
|
||||
|
||||
await remoteConnections.DevDivConnection.TryRunPipelineAsync(product.DartLabPipelineName, repositoryParams, runPipelineParameters, logger).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, e.Message);
|
||||
return -1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupDirectory(targetDirectory, logger);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<string?> GetLatestShaFromPullRequestAsync(IProduct product, HttpClient gitHubClient, int prNumber, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = $"/repos/dotnet/{product.Name.ToLower()}/pulls/{prNumber}";
|
||||
var response = await gitHubClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var prData = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return prData?["head"]?["sha"]?.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError($"Failed to retrieve PR data from GitHub. Status code: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static async Task PushPRToInternalAsync(IProduct product, int prNumber, string azureBranchName, ILogger logger, string sha, string targetDirectory, CancellationToken cancellationToken)
|
||||
{
|
||||
var initCommand = $"init";
|
||||
await RunGitCommandAsync(initCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false); ;
|
||||
|
||||
var addGithubRemoteCommand = $"remote add {product.Name.ToLower()} {product.RepoHttpBaseUrl}.git";
|
||||
await RunGitCommandAsync(addGithubRemoteCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var repoBaseUrl = string.IsNullOrEmpty(product.InternalRepoBaseUrl) ? product.RepoHttpBaseUrl : product.InternalRepoBaseUrl;
|
||||
var addInternalRemoteCommand = $"remote add internal {repoBaseUrl}";
|
||||
await RunGitCommandAsync(addInternalRemoteCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchCommand = $"fetch {product.Name.ToLower()} pull/{prNumber}/head";
|
||||
await RunGitCommandAsync(fetchCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var checkoutCommand = $"checkout {sha}";
|
||||
await RunGitCommandAsync(checkoutCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var checkoutNewBranchCommand = $"checkout -b {azureBranchName}";
|
||||
await RunGitCommandAsync(checkoutNewBranchCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pushCommand = $"push internal {azureBranchName}";
|
||||
await RunGitCommandAsync(pushCommand, logger, targetDirectory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RunGitCommandAsync(string command, ILogger logger, string workingDirectory, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation($"Running command: {command}");
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
Arguments = $"{command}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = workingDirectory
|
||||
};
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = processStartInfo
|
||||
};
|
||||
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, args) => outputBuilder.AppendLine(args.Data);
|
||||
process.ErrorDataReceived += (sender, args) => errorBuilder.AppendLine(args.Data);
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
// Ensure the output and error streams are fully read
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var output = outputBuilder.ToString();
|
||||
var error = errorBuilder.ToString();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
logger.LogInformation($"Command succeeded!");
|
||||
logger.LogInformation(output);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError($"Command failed!");
|
||||
logger.LogError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateDirectory(int prNumber)
|
||||
{
|
||||
var targetDirectory = Path.Combine(Path.GetTempPath(), $"pr-{prNumber}");
|
||||
var counter = 1;
|
||||
while (Directory.Exists(targetDirectory))
|
||||
{
|
||||
targetDirectory = Path.Combine(Path.GetTempPath(), $"pr-{prNumber}-{counter}");
|
||||
counter++;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
return targetDirectory;
|
||||
}
|
||||
|
||||
private static void CleanupDirectory(string? directory, ILogger logger)
|
||||
{
|
||||
if (directory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(directory);
|
||||
var dirs = Directory.GetDirectories(directory);
|
||||
|
||||
foreach (string file in files)
|
||||
{
|
||||
System.IO.File.SetAttributes(file, FileAttributes.Normal);
|
||||
System.IO.File.Delete(file);
|
||||
}
|
||||
|
||||
foreach (string dir in dirs)
|
||||
{
|
||||
CleanupDirectory(dir, logger);
|
||||
}
|
||||
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,8 @@ internal class FSharp : IProduct
|
|||
public string Name => "F#";
|
||||
|
||||
public string RepoHttpBaseUrl => "https://github.com/dotnet/fsharp";
|
||||
public string InternalRepoBaseUrl => "";
|
||||
|
||||
public string RepoSshBaseUrl => "git@github.com:dotnet/fsharp.git";
|
||||
public string GitUserName => "";
|
||||
public string GitEmail => "";
|
||||
|
@ -17,6 +19,7 @@ internal class FSharp : IProduct
|
|||
public string ComponentName => "Microsoft.FSharp";
|
||||
public string? PackageName => null;
|
||||
public string? PackagePropsFileName => null;
|
||||
public string? DartLabPipelineName => null;
|
||||
public string? ArtifactsFolderName => null;
|
||||
public string[] ArtifactsSubFolderNames => Array.Empty<string>();
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ internal interface IProduct
|
|||
|
||||
string RepoHttpBaseUrl { get; }
|
||||
string RepoSshBaseUrl { get; }
|
||||
string InternalRepoBaseUrl { get; }
|
||||
|
||||
// If commits need to be made against the repo, which user gets the credit
|
||||
string GitUserName { get; }
|
||||
|
@ -19,6 +20,7 @@ internal interface IProduct
|
|||
string ComponentName { get; }
|
||||
string? PackageName { get; }
|
||||
string? PackagePropsFileName { get; }
|
||||
string? DartLabPipelineName { get; }
|
||||
string? ArtifactsFolderName { get; }
|
||||
string[] ArtifactsSubFolderNames { get; }
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ internal class Razor : IProduct
|
|||
public string Name => "Razor";
|
||||
|
||||
public string RepoHttpBaseUrl => "https://github.com/dotnet/razor";
|
||||
public string InternalRepoBaseUrl => "";
|
||||
|
||||
public string RepoSshBaseUrl => "git@github.com:dotnet/razor.git";
|
||||
public string GitUserName => "dotnet bot";
|
||||
public string GitEmail => "dotnet-bot@microsoft.com";
|
||||
|
@ -17,10 +19,10 @@ internal class Razor : IProduct
|
|||
public string ComponentName => "Microsoft.VisualStudio.RazorExtension";
|
||||
public string? PackageName => null;
|
||||
public string? PackagePropsFileName => null;
|
||||
public string? DartLabPipelineName => null;
|
||||
public string? ArtifactsFolderName => null;
|
||||
public string[] ArtifactsSubFolderNames => Array.Empty<string>();
|
||||
|
||||
|
||||
public string? GetBuildPipelineName(string buildProjectName)
|
||||
=> buildProjectName switch
|
||||
{
|
||||
|
|
|
@ -9,6 +9,8 @@ internal class Roslyn : IProduct
|
|||
public string Name => "Roslyn";
|
||||
|
||||
public string RepoHttpBaseUrl => "https://github.com/dotnet/roslyn";
|
||||
public string InternalRepoBaseUrl => "https://dnceng.visualstudio.com/internal/_git/dotnet-roslyn";
|
||||
|
||||
public string RepoSshBaseUrl => "git@github.com:dotnet/roslyn.git";
|
||||
public string GitUserName => "dotnet bot";
|
||||
public string GitEmail => "dotnet-bot@microsoft.com";
|
||||
|
@ -17,10 +19,10 @@ internal class Roslyn : IProduct
|
|||
public string ComponentName => "Microsoft.CodeAnalysis.LanguageServices";
|
||||
public string? PackageName => "VS.ExternalAPIs.Roslyn";
|
||||
public string? PackagePropsFileName => "src/ConfigData/Packages/roslyn.props";
|
||||
public string? DartLabPipelineName => "Roslyn Integration CI DartLab";
|
||||
public string? ArtifactsFolderName => "PackageArtifacts";
|
||||
public string[] ArtifactsSubFolderNames => new[] { "PackageArtifacts/PreRelease", "PackageArtifacts/Release" };
|
||||
|
||||
|
||||
public string? GetBuildPipelineName(string buildProjectName)
|
||||
=> buildProjectName switch
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ internal class TypeScript : IProduct
|
|||
public string Name => "TypeScript";
|
||||
|
||||
public string RepoHttpBaseUrl => "https://devdiv.visualstudio.com/DevDiv/_git/TypeScript-VS";
|
||||
public string InternalRepoBaseUrl => "";
|
||||
public string RepoSshBaseUrl => "devdiv@vs-ssh.visualstudio.com:v3/devdiv/DevDiv/TypeScript-VS";
|
||||
public string GitUserName => "";
|
||||
public string GitEmail => "";
|
||||
|
@ -16,6 +17,7 @@ internal class TypeScript : IProduct
|
|||
public string ComponentJsonFileName => @".corext\Configs\components.json";
|
||||
public string ComponentName => "TypeScript_Tools";
|
||||
public string? PackageName => "VS.ExternalAPIs.TypeScript.SourceMapReader.dev15";
|
||||
public string? DartLabPipelineName => null;
|
||||
public string? ArtifactsFolderName => null;
|
||||
public string[] ArtifactsSubFolderNames => Array.Empty<string>();
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// See the License.txt file in the project root for more information.
|
||||
|
||||
using System.Reflection;
|
||||
using Microsoft.Azure.Pipelines.WebApi;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.TeamFoundation.Build.WebApi;
|
||||
using Microsoft.TeamFoundation.Core.WebApi;
|
||||
|
@ -23,6 +25,7 @@ internal sealed class AzDOConnection : IDisposable
|
|||
public HttpClient NuGetClient { get; }
|
||||
public FileContainerHttpClient ContainerClient { get; }
|
||||
public ProjectHttpClient ProjectClient { get; }
|
||||
public PipelinesHttpClient PipelinesHttpClient { get; }
|
||||
|
||||
public AzDOConnection(VssConnection vssConnection, string projectName)
|
||||
{
|
||||
|
@ -37,6 +40,8 @@ internal sealed class AzDOConnection : IDisposable
|
|||
ContainerClient = Connection.GetClient<FileContainerHttpClient>();
|
||||
|
||||
ProjectClient = Connection.GetClient<ProjectHttpClient>();
|
||||
|
||||
PipelinesHttpClient = Connection.GetClient<PipelinesHttpClient>();
|
||||
}
|
||||
|
||||
public async Task<List<Build>?> TryGetBuildsAsync(string pipelineName, string? buildNumber = null, ILogger? logger = null, int? maxFetchingVsBuildNumber = null, BuildResult? resultsFilter = null, BuildQueryOrder? buildQueryOrder = null)
|
||||
|
@ -64,6 +69,23 @@ internal sealed class AzDOConnection : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public async Task TryRunPipelineAsync(string? pipelineName, Dictionary<string, RepositoryResourceParameters> repositoryParams, RunPipelineParameters runPipelineParams, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var buildDefinition = (await BuildClient.GetDefinitionsAsync(BuildProjectName, name: pipelineName)).Single();
|
||||
|
||||
var repositoryField = runPipelineParams.Resources.GetType().GetField("m_repositories", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
repositoryField?.SetValue(runPipelineParams.Resources, repositoryParams);
|
||||
var run = await PipelinesHttpClient.RunPipelineAsync(runPipelineParams, BuildProjectName, buildDefinition.Id);
|
||||
logger.LogInformation($"Pipeline running at: {((ReferenceLink)run.Links.Links["web"]).Href}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error running pipeline: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Licensed to the.NET Foundation under one or more agreements.
|
||||
// Licensed to the.NET Foundation under one or more agreements.
|
||||
// The.NET Foundation licenses this file to you under the MIT license.
|
||||
// See the License.txt file in the project root for more information.
|
||||
|
||||
|
@ -6,11 +6,10 @@ using System.Net.Http.Headers;
|
|||
using Maestro.Common;
|
||||
using Maestro.Common.AzureDevOpsTokens;
|
||||
using Microsoft.RoslynTools.Authentication;
|
||||
using Microsoft.RoslynTools.Utilities;
|
||||
using Microsoft.VisualStudio.Services.Common;
|
||||
using Microsoft.VisualStudio.Services.WebApi;
|
||||
|
||||
namespace Microsoft.RoslynTools.PRTagger;
|
||||
namespace Microsoft.RoslynTools.Utilities;
|
||||
|
||||
internal record RemoteConnections : IDisposable
|
||||
{
|
Загрузка…
Ссылка в новой задаче