diff --git a/src/Tools/CLI/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs index 6249940308..860a874617 100644 --- a/src/Tools/CLI/Commands/UpgradeCommand.cs +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using FSH.CLI.Models; +using FSH.CLI.Services; using Spectre.Console; using Spectre.Console.Cli; @@ -44,6 +45,11 @@ internal sealed class Settings : CommandSettings [Description("Show what would be changed without making modifications")] [DefaultValue(false)] public bool DryRun { get; set; } + + [CommandOption("--include-prerelease")] + [Description("Include prerelease versions")] + [DefaultValue(false)] + public bool IncludePrerelease { get; set; } } public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) @@ -60,7 +66,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se // Show current status AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[blue]FSH Upgrade[/]"); + AnsiConsole.MarkupLine("[blue]FSH Upgrade[/]"); AnsiConsole.MarkupLine($"[dim]Project:[/] {Path.GetFullPath(settings.Path)}"); AnsiConsole.MarkupLine($"[dim]Current version:[/] [yellow]{manifest.FshVersion}[/]"); AnsiConsole.WriteLine(); @@ -89,67 +95,165 @@ public override async Task ExecuteAsync(CommandContext context, Settings se private static void ShowUsageHelp() { AnsiConsole.MarkupLine("[yellow]Usage:[/]"); - AnsiConsole.MarkupLine(" [green]fsh upgrade --check[/] Check for available upgrades"); - AnsiConsole.MarkupLine(" [green]fsh upgrade --apply[/] Apply available upgrades"); - AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --skip-breaking[/] Apply safe updates only"); - AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --dry-run[/] Preview changes without applying"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --check[/] Check for available upgrades"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply[/] Apply available upgrades"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --skip-breaking[/] Apply safe updates only"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --dry-run[/] Preview changes without applying"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --check --include-prerelease[/] Include prereleases"); AnsiConsole.WriteLine(); } - private static Task CheckForUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) + private static async Task CheckForUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) { - // TODO: Sprint 2 - Implement upgrade check - // 1. Fetch latest release info from GitHub API - // 2. Compare versions - // 3. Show available changes + using var githubService = new GitHubReleaseService(); - AnsiConsole.MarkupLine("[yellow]⚠ Upgrade check not yet implemented[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[dim]Coming in Sprint 2:[/]"); - AnsiConsole.MarkupLine("[dim] • GitHub API integration for release fetching[/]"); - AnsiConsole.MarkupLine("[dim] • Version comparison logic[/]"); - AnsiConsole.MarkupLine("[dim] • Package diff detection[/]"); - AnsiConsole.WriteLine(); + // Fetch latest release + GitHubRelease? latestRelease = null; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Checking for updates...", async ctx => + { + if (settings.IncludePrerelease) + { + // Get all releases and find newest + var releases = await githubService.GetReleasesAsync(10, cancellationToken); + latestRelease = releases.FirstOrDefault(); + } + else + { + latestRelease = await githubService.GetLatestReleaseAsync(cancellationToken); + } + }); + + if (latestRelease == null) + { + AnsiConsole.MarkupLine("[yellow]⚠[/] Could not fetch release information from GitHub."); + AnsiConsole.MarkupLine("[dim]Check your internet connection or try again later.[/]"); + return 1; + } - // Placeholder output showing what it will look like - AnsiConsole.MarkupLine("[blue]Preview of planned output:[/]"); + var latestVersion = latestRelease.Version; + var comparison = VersionComparer.CompareVersions(manifest.FshVersion, latestVersion); + + // Show version comparison + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("[blue]Version[/]") + .AddColumn("[blue]Value[/]"); + + table.AddRow("Current", $"[yellow]{manifest.FshVersion}[/]"); + table.AddRow("Latest", comparison < 0 ? $"[green]{latestVersion}[/]" : $"[dim]{latestVersion}[/]"); + + if (latestRelease.Prerelease) + { + table.AddRow("Type", "[yellow]Prerelease[/]"); + } + + AnsiConsole.Write(table); AnsiConsole.WriteLine(); - var panel = new Panel( - """ - [green]FSH Upgrade Check[/] - - Current: [yellow]10.0.0[/] - Latest: [green]10.1.0[/] - - [blue]Changes available:[/] - - BuildingBlocks/Web: - [green]+[/] Added RateLimitingMiddleware - [yellow]~[/] Modified ExceptionHandler (non-breaking) - - Modules/Identity: - [green]+[/] Added MFA support - [red]![/] Breaking: IUserService signature changed - - Directory.Packages.props: - [yellow]~[/] 12 package updates - - Run '[green]fsh upgrade --apply[/]' to upgrade. - Run '[green]fsh upgrade --apply --skip-breaking[/]' for safe updates only. - """) + if (comparison >= 0) + { + AnsiConsole.MarkupLine("[green]✓[/] You're up to date!"); + return 0; + } + + // Fetch package versions for comparison + AnsiConsole.MarkupLine("[dim]Analyzing changes...[/]"); + + var currentPackagesProps = await GetLocalPackagesPropsAsync(settings.Path); + var latestPackagesProps = await githubService.GetPackagesPropsAsync(latestRelease.TagName, cancellationToken); + + if (currentPackagesProps != null && latestPackagesProps != null) { - Border = BoxBorder.Rounded, - Padding = new Padding(2, 1) - }; + var currentVersions = VersionComparer.ParsePackagesProps(currentPackagesProps); + var latestVersions = VersionComparer.ParsePackagesProps(latestPackagesProps); + var diff = VersionComparer.Compare(currentVersions, latestVersions); + + if (diff.HasChanges) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Package Changes:[/]"); + AnsiConsole.WriteLine(); + + if (diff.Updated.Count > 0) + { + var updateTable = new Table() + .Border(TableBorder.Simple) + .AddColumn("Package") + .AddColumn("Current") + .AddColumn("Latest") + .AddColumn("Status"); + + foreach (var update in diff.Updated.OrderBy(u => u.Package)) + { + var status = update.IsBreaking ? "[red]Breaking[/]" : "[green]Safe[/]"; + updateTable.AddRow( + $"[dim]{update.Package}[/]", + update.FromVersion, + $"[green]{update.ToVersion}[/]", + status); + } - AnsiConsole.Write(panel); + AnsiConsole.Write(updateTable); + } + + if (diff.Added.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[green]New packages:[/]"); + foreach (var added in diff.Added.OrderBy(a => a.Package)) + { + AnsiConsole.MarkupLine($" [green]+[/] {added.Package} [dim]({added.Version})[/]"); + } + } + + if (diff.Removed.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[red]Removed packages:[/]"); + foreach (var removed in diff.Removed.OrderBy(r => r.Package)) + { + AnsiConsole.MarkupLine($" [red]-[/] {removed.Package} [dim]({removed.Version})[/]"); + } + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[dim]Total:[/] {diff.Updated.Count} updates, {diff.Added.Count} new, {diff.Removed.Count} removed"); + + if (diff.HasBreakingChanges) + { + AnsiConsole.MarkupLine("[yellow]⚠[/] Some updates may contain breaking changes."); + } + } + } + + // Show release notes summary if available + if (!string.IsNullOrEmpty(latestRelease.Body)) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Release Notes:[/]"); + + var panel = new Panel(TruncateReleaseNotes(latestRelease.Body, 500)) + { + Border = BoxBorder.Rounded, + Padding = new Padding(1, 0) + }; + AnsiConsole.Write(panel); + } + + // Show next steps + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[dim]Release URL:[/] {latestRelease.HtmlUrl}"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("Run [green]fsh upgrade --apply[/] to upgrade."); + AnsiConsole.MarkupLine("Run [green]fsh upgrade --apply --skip-breaking[/] for safe updates only."); AnsiConsole.WriteLine(); - return Task.FromResult(0); + return 0; } - private static Task ApplyUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) + private static async Task ApplyUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) { // TODO: Sprint 3 - Implement upgrade apply // 1. Fetch latest release @@ -175,6 +279,38 @@ private static Task ApplyUpgradesAsync(FshManifest manifest, Settings setti AnsiConsole.MarkupLine("[dim]Skip breaking mode - would skip breaking changes[/]"); } - return Task.FromResult(0); + return 0; + } + + private static async Task GetLocalPackagesPropsAsync(string projectPath) + { + var packagesPropsPath = Path.Combine(projectPath, "src", "Directory.Packages.props"); + + if (!File.Exists(packagesPropsPath)) + { + // Try root + packagesPropsPath = Path.Combine(projectPath, "Directory.Packages.props"); + } + + if (!File.Exists(packagesPropsPath)) + { + return null; + } + + return await File.ReadAllTextAsync(packagesPropsPath); + } + + private static string TruncateReleaseNotes(string notes, int maxLength) + { + if (string.IsNullOrEmpty(notes)) + return string.Empty; + + // Remove markdown links for cleaner display + notes = System.Text.RegularExpressions.Regex.Replace(notes, @"\[([^\]]+)\]\([^\)]+\)", "$1"); + + if (notes.Length <= maxLength) + return notes; + + return notes[..maxLength] + "..."; } } diff --git a/src/Tools/CLI/Services/GitHubReleaseService.cs b/src/Tools/CLI/Services/GitHubReleaseService.cs new file mode 100644 index 0000000000..356041d5fc --- /dev/null +++ b/src/Tools/CLI/Services/GitHubReleaseService.cs @@ -0,0 +1,185 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace FSH.CLI.Services; + +/// +/// Service for fetching FSH release information from GitHub. +/// +internal sealed class GitHubReleaseService : IDisposable +{ + private const string GitHubApiBase = "https://api.github.com"; + private const string RepoOwner = "fullstackhero"; + private const string RepoName = "dotnet-starter-kit"; + + private readonly HttpClient _httpClient; + private bool _disposed; + + public GitHubReleaseService() + { + _httpClient = new HttpClient + { + BaseAddress = new Uri(GitHubApiBase), + DefaultRequestHeaders = + { + { "User-Agent", "FSH-CLI" }, + { "Accept", "application/vnd.github+json" } + } + }; + } + + /// + /// Get the latest release from GitHub. + /// + public async Task GetLatestReleaseAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync( + $"/repos/{RepoOwner}/{RepoName}/releases/latest", + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } + + /// + /// Get all releases from GitHub (for version history). + /// + public async Task> GetReleasesAsync(int count = 10, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync( + $"/repos/{RepoOwner}/{RepoName}/releases?per_page={count}", + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return []; + } + + return await response.Content.ReadFromJsonAsync>(cancellationToken) ?? []; + } + catch (HttpRequestException) + { + return []; + } + catch (TaskCanceledException) + { + return []; + } + } + + /// + /// Get a specific release by tag name. + /// + public async Task GetReleaseByTagAsync(string tag, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync( + $"/repos/{RepoOwner}/{RepoName}/releases/tags/{tag}", + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } + + /// + /// Fetch the Directory.Packages.props content from a specific tag/branch. + /// + public async Task GetPackagesPropsAsync(string refName, CancellationToken cancellationToken = default) + { + try + { + // Use raw.githubusercontent.com for file content + using var rawClient = new HttpClient(); + var url = $"https://raw.githubusercontent.com/{RepoOwner}/{RepoName}/{refName}/src/Directory.Packages.props"; + + var response = await rawClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } + + public void Dispose() + { + if (!_disposed) + { + _httpClient.Dispose(); + _disposed = true; + } + } +} + +/// +/// GitHub release information. +/// +internal sealed class GitHubRelease +{ + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("body")] + public string Body { get; set; } = string.Empty; + + [JsonPropertyName("published_at")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonPropertyName("html_url")] + public string HtmlUrl { get; set; } = string.Empty; + + [JsonPropertyName("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("draft")] + public bool Draft { get; set; } + + /// + /// Extract version number from tag name (strips 'v' prefix if present). + /// + public string Version => TagName.TrimStart('v', 'V'); +} diff --git a/src/Tools/CLI/Services/VersionComparer.cs b/src/Tools/CLI/Services/VersionComparer.cs new file mode 100644 index 0000000000..d70868232b --- /dev/null +++ b/src/Tools/CLI/Services/VersionComparer.cs @@ -0,0 +1,195 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace FSH.CLI.Services; + +/// +/// Compares package versions between current project and latest release. +/// +internal static partial class VersionComparer +{ + /// + /// Parse Directory.Packages.props XML content and extract package versions. + /// + public static Dictionary ParsePackagesProps(string xmlContent) + { + var packages = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var doc = XDocument.Parse(xmlContent); + var packageVersions = doc.Descendants("PackageVersion"); + + foreach (var pv in packageVersions) + { + var include = pv.Attribute("Include")?.Value; + var version = pv.Attribute("Version")?.Value; + + if (!string.IsNullOrEmpty(include) && !string.IsNullOrEmpty(version)) + { + packages[include] = version; + } + } + } + catch (Exception) + { + // Return empty dict on parse failure + } + + return packages; + } + + /// + /// Compare two sets of package versions and return the differences. + /// + public static VersionDiff Compare( + Dictionary currentVersions, + Dictionary latestVersions) + { + var diff = new VersionDiff(); + + // Check for updates and additions + foreach (var (package, latestVersion) in latestVersions) + { + if (currentVersions.TryGetValue(package, out var currentVersion)) + { + var comparison = CompareVersions(currentVersion, latestVersion); + if (comparison < 0) + { + diff.Updated.Add(new PackageUpdate(package, currentVersion, latestVersion, IsBreaking(package, currentVersion, latestVersion))); + } + } + else + { + diff.Added.Add(new PackageChange(package, latestVersion)); + } + } + + // Check for removals + foreach (var (package, currentVersion) in currentVersions) + { + if (!latestVersions.ContainsKey(package)) + { + diff.Removed.Add(new PackageChange(package, currentVersion)); + } + } + + return diff; + } + + /// + /// Compare two semantic version strings. + /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + /// + public static int CompareVersions(string v1, string v2) + { + // Handle null/empty + if (string.IsNullOrEmpty(v1) && string.IsNullOrEmpty(v2)) return 0; + if (string.IsNullOrEmpty(v1)) return -1; + if (string.IsNullOrEmpty(v2)) return 1; + + // Try to parse as Version first + var parts1 = ParseVersionParts(v1); + var parts2 = ParseVersionParts(v2); + + // Compare major.minor.patch + for (int i = 0; i < Math.Max(parts1.NumericParts.Count, parts2.NumericParts.Count); i++) + { + var p1 = i < parts1.NumericParts.Count ? parts1.NumericParts[i] : 0; + var p2 = i < parts2.NumericParts.Count ? parts2.NumericParts[i] : 0; + + if (p1 < p2) return -1; + if (p1 > p2) return 1; + } + + // If numeric parts equal, compare prerelease + // No prerelease > prerelease (1.0.0 > 1.0.0-beta) + if (string.IsNullOrEmpty(parts1.Prerelease) && !string.IsNullOrEmpty(parts2.Prerelease)) + return 1; + if (!string.IsNullOrEmpty(parts1.Prerelease) && string.IsNullOrEmpty(parts2.Prerelease)) + return -1; + + return string.Compare(parts1.Prerelease, parts2.Prerelease, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determine if a version change is potentially breaking. + /// + private static bool IsBreaking(string package, string fromVersion, string toVersion) + { + var from = ParseVersionParts(fromVersion); + var to = ParseVersionParts(toVersion); + + // Major version bump is breaking + if (to.NumericParts.Count > 0 && from.NumericParts.Count > 0) + { + if (to.NumericParts[0] > from.NumericParts[0]) + return true; + } + + // FSH-specific: certain packages are known to have breaking changes + // This can be expanded based on release notes + return false; + } + + private static VersionParts ParseVersionParts(string version) + { + var parts = new VersionParts(); + + // Split on dash for prerelease + var dashIndex = version.IndexOf('-'); + var mainPart = dashIndex >= 0 ? version[..dashIndex] : version; + parts.Prerelease = dashIndex >= 0 ? version[(dashIndex + 1)..] : string.Empty; + + // Parse numeric parts + var numericMatches = NumericPartRegex().Matches(mainPart); + foreach (Match match in numericMatches) + { + if (int.TryParse(match.Value, out var num)) + { + parts.NumericParts.Add(num); + } + } + + return parts; + } + + [GeneratedRegex(@"\d+")] + private static partial Regex NumericPartRegex(); + + private sealed class VersionParts + { + public List NumericParts { get; } = []; + public string Prerelease { get; set; } = string.Empty; + } +} + +/// +/// Result of comparing two sets of package versions. +/// +internal sealed class VersionDiff +{ + public List Updated { get; } = []; + public List Added { get; } = []; + public List Removed { get; } = []; + + public bool HasChanges => Updated.Count > 0 || Added.Count > 0 || Removed.Count > 0; + public int TotalChanges => Updated.Count + Added.Count + Removed.Count; + public bool HasBreakingChanges => Updated.Any(u => u.IsBreaking); +} + +/// +/// Represents a package version update. +/// +internal sealed record PackageUpdate( + string Package, + string FromVersion, + string ToVersion, + bool IsBreaking); + +/// +/// Represents an added or removed package. +/// +internal sealed record PackageChange( + string Package, + string Version);