mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
409 lines
14 KiB
C#
409 lines
14 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using GitHub.Copilot.SDK;
|
|
using Aryx.AgentHost.Contracts;
|
|
|
|
namespace Aryx.AgentHost.Services;
|
|
|
|
internal static partial class CopilotConnectionMetadataResolver
|
|
{
|
|
private static readonly string[] LatestVersionIndicators =
|
|
[
|
|
"running the latest version",
|
|
"already up to date",
|
|
"is up to date",
|
|
];
|
|
|
|
private static readonly string[] OutdatedVersionIndicators =
|
|
[
|
|
"newer version",
|
|
"new version available",
|
|
"update available",
|
|
"download link",
|
|
];
|
|
|
|
private static readonly TimeSpan CopilotVersionTimeout = TimeSpan.FromSeconds(5);
|
|
private static readonly TimeSpan GitHubContextTimeout = TimeSpan.FromSeconds(4);
|
|
|
|
internal static async Task<SidecarCopilotCliVersionDiagnosticsDto> GetCliVersionDiagnosticsAsync(
|
|
CopilotCliContext cliContext,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
(string executablePath, string[] arguments) = CreateCliCommand(cliContext, "version");
|
|
CommandResult result = await RunProcessAsync(
|
|
executablePath: executablePath,
|
|
arguments: arguments,
|
|
environment: cliContext.Environment,
|
|
timeout: CopilotVersionTimeout,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
return ParseCliVersionOutput(result.StandardOutput, result.StandardError, result.ExitCode);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return new SidecarCopilotCliVersionDiagnosticsDto
|
|
{
|
|
Status = "unknown",
|
|
Detail = "Timed out while checking the installed GitHub Copilot CLI version.",
|
|
};
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return new SidecarCopilotCliVersionDiagnosticsDto
|
|
{
|
|
Status = "unknown",
|
|
Detail = $"Failed to check the installed GitHub Copilot CLI version: {exception.Message}",
|
|
};
|
|
}
|
|
}
|
|
|
|
internal static (string ExecutablePath, string[] Arguments) CreateCliCommand(
|
|
CopilotCliContext cliContext,
|
|
params string[] arguments)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(cliContext);
|
|
ArgumentNullException.ThrowIfNull(arguments);
|
|
|
|
return (
|
|
cliContext.LaunchPath,
|
|
[.. cliContext.LaunchArgs, .. arguments]);
|
|
}
|
|
|
|
internal static SidecarCopilotCliVersionDiagnosticsDto ParseCliVersionOutput(
|
|
string? standardOutput,
|
|
string? standardError = null,
|
|
int exitCode = 0)
|
|
{
|
|
string output = NormalizeOutput(standardOutput, standardError);
|
|
string? installedVersion = ExtractInstalledVersion(output);
|
|
string status = ClassifyCliVersionStatus(output, exitCode);
|
|
string? latestVersion = status switch
|
|
{
|
|
"latest" => installedVersion,
|
|
"outdated" => ExtractLatestVersion(output, installedVersion),
|
|
_ => null,
|
|
};
|
|
|
|
string? detail = string.IsNullOrWhiteSpace(output)
|
|
? null
|
|
: output;
|
|
|
|
if (detail is null && status == "unknown")
|
|
{
|
|
detail = exitCode == 0
|
|
? "GitHub Copilot CLI version could not be determined."
|
|
: $"GitHub Copilot CLI version check exited with code {exitCode}.";
|
|
}
|
|
|
|
return new SidecarCopilotCliVersionDiagnosticsDto
|
|
{
|
|
Status = status,
|
|
InstalledVersion = installedVersion,
|
|
LatestVersion = latestVersion,
|
|
Detail = detail,
|
|
};
|
|
}
|
|
|
|
internal static string ClassifyCliVersionStatus(string output, int exitCode = 0)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(output))
|
|
{
|
|
return "unknown";
|
|
}
|
|
|
|
if (LatestVersionIndicators.Any(indicator =>
|
|
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return "latest";
|
|
}
|
|
|
|
if (OutdatedVersionIndicators.Any(indicator =>
|
|
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return "outdated";
|
|
}
|
|
|
|
return exitCode == 0 && ExtractInstalledVersion(output) is not null
|
|
? "unknown"
|
|
: "unknown";
|
|
}
|
|
|
|
internal static string? ExtractInstalledVersion(string output)
|
|
{
|
|
Match match = SemanticVersionPattern().Match(output);
|
|
return match.Success ? match.Groups["version"].Value : null;
|
|
}
|
|
|
|
internal static string? ExtractLatestVersion(string output, string? installedVersion)
|
|
{
|
|
return SemanticVersionPattern()
|
|
.Matches(output)
|
|
.Select(match => match.Groups["version"].Value)
|
|
.FirstOrDefault(version =>
|
|
!string.Equals(version, installedVersion, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
internal static async Task<GetAuthStatusResponse?> TryGetAuthStatusAsync(
|
|
CopilotClient client,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine($"[aryx sidecar] Failed to inspect Copilot auth status: {exception.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
internal static async Task<SidecarCopilotAccountDiagnosticsDto?> CreateAccountDiagnosticsAsync(
|
|
GetAuthStatusResponse? authStatus,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (authStatus is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string? normalizedHost = NormalizeHost(authStatus.Host);
|
|
IReadOnlyList<string>? organizations = null;
|
|
|
|
if (authStatus.IsAuthenticated
|
|
&& !string.IsNullOrWhiteSpace(authStatus.Login)
|
|
&& !string.IsNullOrWhiteSpace(normalizedHost))
|
|
{
|
|
organizations = await TryListOrganizationsAsync(
|
|
authStatus.Login,
|
|
normalizedHost,
|
|
environment,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return new SidecarCopilotAccountDiagnosticsDto
|
|
{
|
|
Authenticated = authStatus.IsAuthenticated,
|
|
Login = authStatus.Login,
|
|
Host = normalizedHost,
|
|
AuthType = authStatus.AuthType,
|
|
StatusMessage = authStatus.StatusMessage,
|
|
Organizations = organizations,
|
|
};
|
|
}
|
|
|
|
internal static string? NormalizeHost(string? host)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(host))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string trimmed = host.Trim();
|
|
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
|
|
{
|
|
return uri.Host;
|
|
}
|
|
|
|
return trimmed
|
|
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.TrimEnd('/');
|
|
}
|
|
|
|
internal static IReadOnlyList<string> ParseOrganizationsOutput(string? output)
|
|
{
|
|
return (output ?? string.Empty)
|
|
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<string>?> TryListOrganizationsAsync(
|
|
string login,
|
|
string host,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CommandResult? loginResult = await TryRunGhCommandAsync(
|
|
["api", "--hostname", host, "user", "--jq", ".login"],
|
|
environment,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
string? resolvedLogin = loginResult is { ExitCode: 0 }
|
|
? loginResult.StandardOutput.Trim()
|
|
: null;
|
|
|
|
if (string.IsNullOrWhiteSpace(resolvedLogin)
|
|
|| !string.Equals(resolvedLogin, login, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
CommandResult? organizationsResult = await TryRunGhCommandAsync(
|
|
["api", "--hostname", host, "user/orgs", "--jq", ".[].login"],
|
|
environment,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
return organizationsResult is { ExitCode: 0 }
|
|
? ParseOrganizationsOutput(organizationsResult.StandardOutput)
|
|
: null;
|
|
}
|
|
|
|
private static async Task<CommandResult?> TryRunGhCommandAsync(
|
|
IReadOnlyList<string> arguments,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return await RunProcessAsync(
|
|
executablePath: OperatingSystem.IsWindows() ? "gh.exe" : "gh",
|
|
arguments,
|
|
environment,
|
|
GitHubContextTimeout,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task<CommandResult> RunProcessAsync(
|
|
string executablePath,
|
|
IReadOnlyList<string> arguments,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutSource.CancelAfter(timeout);
|
|
|
|
using Process process = new()
|
|
{
|
|
StartInfo = CreateProcessStartInfo(executablePath, arguments, environment),
|
|
};
|
|
|
|
if (!process.Start())
|
|
{
|
|
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
|
|
}
|
|
|
|
process.StandardInput.Close();
|
|
Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token);
|
|
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(timeoutSource.Token);
|
|
|
|
await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false);
|
|
|
|
return new CommandResult(
|
|
process.ExitCode,
|
|
await standardOutputTask.ConfigureAwait(false),
|
|
await standardErrorTask.ConfigureAwait(false));
|
|
}
|
|
|
|
private static ProcessStartInfo CreateProcessStartInfo(
|
|
string executablePath,
|
|
IReadOnlyList<string> arguments,
|
|
IReadOnlyDictionary<string, string> environment)
|
|
{
|
|
ProcessStartInfo startInfo = new()
|
|
{
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
RedirectStandardInput = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8,
|
|
};
|
|
|
|
if (OperatingSystem.IsWindows() && RequiresWindowsShell(executablePath))
|
|
{
|
|
startInfo.FileName = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ComSpec"))
|
|
? "cmd.exe"
|
|
: Environment.GetEnvironmentVariable("ComSpec");
|
|
|
|
startInfo.ArgumentList.Add("/d");
|
|
startInfo.ArgumentList.Add("/s");
|
|
startInfo.ArgumentList.Add("/c");
|
|
startInfo.ArgumentList.Add(BuildWindowsShellCommand(executablePath, arguments));
|
|
}
|
|
else
|
|
{
|
|
startInfo.FileName = executablePath;
|
|
foreach (string argument in arguments)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
}
|
|
|
|
startInfo.Environment.Clear();
|
|
foreach (KeyValuePair<string, string> entry in environment)
|
|
{
|
|
startInfo.Environment[entry.Key] = entry.Value;
|
|
}
|
|
|
|
return startInfo;
|
|
}
|
|
|
|
private static bool RequiresWindowsShell(string executablePath)
|
|
{
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string extension = Path.GetExtension(executablePath);
|
|
return extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)
|
|
|| extension.Equals(".bat", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string BuildWindowsShellCommand(string executablePath, IReadOnlyList<string> arguments)
|
|
{
|
|
IEnumerable<string> tokens = [executablePath, .. arguments];
|
|
return string.Join(" ", tokens.Select(QuoteWindowsShellToken));
|
|
}
|
|
|
|
private static string QuoteWindowsShellToken(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
return "\"\"";
|
|
}
|
|
|
|
bool needsQuotes = value.Any(character =>
|
|
char.IsWhiteSpace(character)
|
|
|| character is '&' or '(' or ')' or '[' or ']' or '{' or '}' or '^' or '=' or ';' or '!' or '+' or ',' or '`' or '~');
|
|
|
|
if (!needsQuotes)
|
|
{
|
|
return value;
|
|
}
|
|
|
|
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
|
}
|
|
|
|
private static string NormalizeOutput(string? standardOutput, string? standardError)
|
|
{
|
|
return string.Join(
|
|
Environment.NewLine,
|
|
new[] { standardOutput, standardError }
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!.Trim()));
|
|
}
|
|
|
|
private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError);
|
|
|
|
[GeneratedRegex(@"\b(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)]
|
|
private static partial Regex SemanticVersionPattern();
|
|
}
|