diff --git a/common/TelemetryEvents/DevHomeDatabase/DatabaseMigrationErrorEvent.cs b/common/TelemetryEvents/DevHomeDatabase/DatabaseMigrationErrorEvent.cs new file mode 100644 index 0000000000..cfdb09ee78 --- /dev/null +++ b/common/TelemetryEvents/DevHomeDatabase/DatabaseMigrationErrorEvent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.Tracing; +using DevHome.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; + +namespace DevHome.Common.TelemetryEvents.DevHomeDatabase; + +[EventData] +public class DatabaseMigrationErrorEvent : EventBase +{ + public override PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServicePerformance; + + public uint PreviousSchemaVersion { get; set; } + + public uint CurrentSchemaVersion { get; set; } + + public int HResult { get; } + + public string ExceptionMessage { get; } = string.Empty; + + public DatabaseMigrationErrorEvent(Exception ex, uint previousSchemaVersion, uint currentSchemaVersion) + { + HResult = ex.HResult; + ExceptionMessage = ex.Message; + PreviousSchemaVersion = previousSchemaVersion; + CurrentSchemaVersion = currentSchemaVersion; + } + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + // No sensitive strings to replace. + } +} diff --git a/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemErrorEvent.cs b/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemErrorEvent.cs index 63b83190c4..42cdfc2a9e 100644 --- a/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemErrorEvent.cs +++ b/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemErrorEvent.cs @@ -19,14 +19,11 @@ public class RepositoryLineItemErrorEvent : EventBase public string ErrorMessage { get; } = string.Empty; - public string RepositoryName { get; } = string.Empty; - - public RepositoryLineItemErrorEvent(string action, int hresult, string errorMessage, string repositoryName) + public RepositoryLineItemErrorEvent(string action, Exception ex) { Action = action; - Hresult = hresult; - ErrorMessage = errorMessage; - RepositoryName = repositoryName; + Hresult = ex.HResult; + ErrorMessage = ex.Message; } public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) diff --git a/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemEvent.cs b/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemEvent.cs index 43505feb0d..a67489920d 100644 --- a/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemEvent.cs +++ b/common/TelemetryEvents/RepositoryManagement/RepositoryLineItemEvent.cs @@ -19,12 +19,9 @@ public class RepositoryLineItemEvent : EventBase public string Action { get; } = string.Empty; - public string RepositoryName { get; } = string.Empty; - - public RepositoryLineItemEvent(string action, string repositoryName) + public RepositoryLineItemEvent(string action) { Action = action; - RepositoryName = repositoryName; } public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) diff --git a/database/DevHome.Database/Assets/MigrationScripts/0To1.sql b/database/DevHome.Database/Assets/MigrationScripts/0To1.sql new file mode 100644 index 0000000000..188ee74598 --- /dev/null +++ b/database/DevHome.Database/Assets/MigrationScripts/0To1.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, + "ProductVersion" TEXT NOT NULL +); + +BEGIN TRANSACTION; + +CREATE TABLE "Repository" ( + "RepositoryId" INTEGER NOT NULL CONSTRAINT "PK_Repository" PRIMARY KEY AUTOINCREMENT, + "RepositoryName" TEXT NOT NULL DEFAULT '', + "RepositoryClonePath" TEXT NOT NULL DEFAULT '', + "IsHidden" INTEGER NOT NULL, + "ConfigurationFileLocation" TEXT NULL DEFAULT '', + "RepositoryUri" TEXT NULL DEFAULT '', + "SourceControlClassId" TEXT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "CreatedUTCDate" TEXT NULL DEFAULT (datetime()), + "UpdatedUTCDate" TEXT NULL DEFAULT (datetime()) +); + +CREATE UNIQUE INDEX "IX_Repository_RepositoryName_RepositoryClonePath" ON "Repository" ("RepositoryName", "RepositoryClonePath"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20240920200626_InitialMigration', '8.0.8'); + +COMMIT; + diff --git a/database/DevHome.Database/Configurations/RepositoryConfiguration.cs b/database/DevHome.Database/Configurations/RepositoryConfiguration.cs new file mode 100644 index 0000000000..e4c388baf2 --- /dev/null +++ b/database/DevHome.Database/Configurations/RepositoryConfiguration.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using DevHome.Common.TelemetryEvents.DevHomeDatabase; +using DevHome.Database.DatabaseModels.RepositoryManagement; +using DevHome.Telemetry; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Serilog; + +namespace DevHome.Database.Configurations; + +public class RepositoryConfiguration : IEntityTypeConfiguration +{ + private static readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryConfiguration)); + + public void Configure(EntityTypeBuilder builder) + { + try + { + builder.Property(x => x.ConfigurationFileLocation).HasDefaultValue(string.Empty); + builder.Property(x => x.RepositoryClonePath).HasDefaultValue(string.Empty).IsRequired(true); + builder.Property(x => x.RepositoryName).HasDefaultValue(string.Empty).IsRequired(true); + builder.Property(x => x.CreatedUTCDate).HasDefaultValueSql("datetime()"); + builder.Property(x => x.UpdatedUTCDate).HasDefaultValueSql("datetime()"); + builder.Property(x => x.RepositoryUri).HasDefaultValue(string.Empty); + builder.ToTable("Repository"); + } + catch (Exception ex) + { + _log.Error(ex, "Error building the repository data model."); + TelemetryFactory.Get().Log( + "DevHome_RepositoryConfiguration_Event", + LogLevel.Critical, + new DatabaseContextErrorEvent("CreatingRepositoryModel", ex)); + } + } +} diff --git a/database/DevHome.Database/DevHome.Database.csproj b/database/DevHome.Database/DevHome.Database.csproj index 276603d02f..33c757588a 100644 --- a/database/DevHome.Database/DevHome.Database.csproj +++ b/database/DevHome.Database/DevHome.Database.csproj @@ -11,6 +11,11 @@ true + + + Always + + diff --git a/database/DevHome.Database/DevHomeDatabaseContext.cs b/database/DevHome.Database/DevHomeDatabaseContext.cs index 73f8f7f82c..e317ad46fe 100644 --- a/database/DevHome.Database/DevHomeDatabaseContext.cs +++ b/database/DevHome.Database/DevHomeDatabaseContext.cs @@ -3,27 +3,29 @@ using System; using System.IO; -using System.Linq.Expressions; +using DevHome.Common.Helpers; using DevHome.Common.TelemetryEvents.DevHomeDatabase; +using DevHome.Database.Configurations; using DevHome.Database.DatabaseModels.RepositoryManagement; +using DevHome.Database.Services; using DevHome.Telemetry; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Serilog; +using Windows.Storage; namespace DevHome.Database; +// TODO: Add documentation around migration and Entity Framework in DevHome. + /// -/// To make the database please run the following in Package Manager Console -/// Update-Database -StartupProject DevHome.Database -Project DevHome.Database -/// -/// TODO: Remove this comment after database migration is implemeneted. -/// TODO: Set up Github detection for files in this project. -/// TODO: Add documentation around migration and Entity Framework in DevHome. +/// Provides access to the database for DevHome. /// -public class DevHomeDatabaseContext : DbContext +public class DevHomeDatabaseContext : DbContext, IDevHomeDatabaseContext { - private const string DatabaseFileName = "DevHome.db"; + // Increment when the schema has changed. + // Should incremenet once per release. + public uint SchemaVersion => 1; private readonly ILogger _log = Log.ForContext("SourceContext", nameof(DevHomeDatabaseContext)); @@ -33,35 +35,36 @@ public class DevHomeDatabaseContext : DbContext public DevHomeDatabaseContext() { - // TODO: How to run the DevHome in VS and not have the file move to the per app location. - DbPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), DatabaseFileName); + if (RuntimeHelper.IsMSIX) + { + DbPath = Path.Join(ApplicationData.Current.LocalFolder.Path, "DevHome.db"); + } + else + { +#if CANARY_BUILD + var databaseFileName = "DevHome_Canary.db"; +#elif STABLE_BUILD + var databaseFileName = "DevHome.db"; +#else + var databaseFileName = "DevHome_dev.db"; +#endif + DbPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), databaseFileName); + } + } + + public DevHomeDatabaseContext(string dbPath) + { + DbPath = dbPath; } protected override void OnModelCreating(ModelBuilder modelBuilder) { - // TODO: Use ServiceExtensions as an example to set up individual - // models using fluent API. Currently, not needed, but will as this method - // will expand as more entities are added. - // If that is too much work these definitions can be placed inside the C# class. try { - // TODO: How to update "UpdatedAt"? - var repositoryEntity = modelBuilder.Entity(); - if (repositoryEntity != null) - { - repositoryEntity.Property(x => x.ConfigurationFileLocation).HasDefaultValue(string.Empty); - repositoryEntity.Property(x => x.RepositoryClonePath).HasDefaultValue(string.Empty).IsRequired(true); - repositoryEntity.Property(x => x.RepositoryName).HasDefaultValue(string.Empty).IsRequired(true); - repositoryEntity.Property(x => x.CreatedUTCDate).HasDefaultValueSql("datetime()"); - repositoryEntity.Property(x => x.UpdatedUTCDate).HasDefaultValueSql("datetime()"); - repositoryEntity.Property(x => x.RepositoryUri).HasDefaultValue(string.Empty); - repositoryEntity.Property(x => x.SourceControlClassId).HasDefaultValue(Guid.Empty); - repositoryEntity.ToTable("Repository"); - } + new RepositoryConfiguration().Configure(modelBuilder.Entity()); } catch (Exception ex) { - // TODO: Notify user the database could not initialize. _log.Error(ex, "Can not build the database model"); TelemetryFactory.Get().Log( "DevHome_DatabaseContext_Event", @@ -74,4 +77,9 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite($"Data Source={DbPath}"); } + + public EntityEntry Add(Repository repository) + { + return Repositories.Add(repository); + } } diff --git a/database/DevHome.Database/Extensions/ServiceExtensions.cs b/database/DevHome.Database/Extensions/ServiceExtensions.cs index b46a738e24..6fa31e9415 100644 --- a/database/DevHome.Database/Extensions/ServiceExtensions.cs +++ b/database/DevHome.Database/Extensions/ServiceExtensions.cs @@ -12,10 +12,16 @@ public static class ServiceExtensions { public static IServiceCollection AddDatabase(this IServiceCollection services, HostBuilderContext context) { - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(); + return services; } } diff --git a/database/DevHome.Database/Factories/CustomMigrationHandlerFactory.cs b/database/DevHome.Database/Factories/CustomMigrationHandlerFactory.cs new file mode 100644 index 0000000000..1e38fbc24d --- /dev/null +++ b/database/DevHome.Database/Factories/CustomMigrationHandlerFactory.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using DevHome.Database.Services; + +namespace DevHome.Database.Factories; + +public class CustomMigrationHandlerFactory : ICustomMigrationHandlerFactory +{ + /// + /// Gets a list of all migrations that can handle the migration from previousSchemaVersion to + /// currentSchemaVersion in priority order (min to max). + /// + /// The schema version upgrading from. + /// The schema version upgrading to. + /// Specifically, this grabs all classes the inherit from ICustomMigration that is not + /// abstract, not generic, and has a parameterless constructor. + /// A list of all objects that can handle the migration, sorted in priority order. + public IReadOnlyList GetCustomMigrationHandlers(uint previousSchemaVersion, uint currentSchemaVersion) + { + var customLogics = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(type => typeof(ICustomMigration).IsAssignableFrom(type)) + .Where(type => + !type.IsAbstract && + !type.IsGenericType && + type.GetConstructor(Array.Empty()) != null) + .Select(type => (ICustomMigration)Activator.CreateInstance(type)!) + .OrderBy(x => x.Priority) + .ToList(); + return customLogics; + } +} diff --git a/database/DevHome.Database/Factories/DevHomeDatabaseContextFactory.cs b/database/DevHome.Database/Factories/DevHomeDatabaseContextFactory.cs index 8893848a53..d9f1d90c39 100644 --- a/database/DevHome.Database/Factories/DevHomeDatabaseContextFactory.cs +++ b/database/DevHome.Database/Factories/DevHomeDatabaseContextFactory.cs @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using DevHome.Database.Services; using Serilog; namespace DevHome.Database.Factories; -public class DevHomeDatabaseContextFactory +public class DevHomeDatabaseContextFactory : IDevHomeDatabaseContextFactory { private readonly ILogger _log = Log.ForContext("SourceContext", nameof(DevHomeDatabaseContextFactory)); - public DevHomeDatabaseContext GetNewContext() + public IDevHomeDatabaseContext GetNewContext() { _log.Information("Making a new DevHome Database Context"); diff --git a/database/DevHome.Database/Factories/SchemaAccessorFactory.cs b/database/DevHome.Database/Factories/SchemaAccessorFactory.cs new file mode 100644 index 0000000000..303c0205fc --- /dev/null +++ b/database/DevHome.Database/Factories/SchemaAccessorFactory.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Helpers; +using DevHome.Database.Services; + +namespace DevHome.Database.Factories; + +/// +/// Accessing the schema file differs between MSIX and winexe. +/// +public class SchemaAccessorFactory : ISchemaAccessFactory +{ + public ISchemaAccessor GenerateSchemaAccessor() + { + if (RuntimeHelper.IsMSIX) + { + return new MSIXSchemaAccessor(); + } + else + { + return new WinExeSchemaAccessor(); + } + } +} diff --git a/database/DevHome.Database/Migrations/20240920200626_InitialMigration.Designer.cs b/database/DevHome.Database/Migrations/20240919221355_0To1.Designer.cs similarity index 100% rename from database/DevHome.Database/Migrations/20240920200626_InitialMigration.Designer.cs rename to database/DevHome.Database/Migrations/20240919221355_0To1.Designer.cs diff --git a/database/DevHome.Database/Migrations/20240920200626_InitialMigration.cs b/database/DevHome.Database/Migrations/20240919221355_0To1.cs similarity index 100% rename from database/DevHome.Database/Migrations/20240920200626_InitialMigration.cs rename to database/DevHome.Database/Migrations/20240919221355_0To1.cs diff --git a/database/DevHome.Database/SchemaAccessorConstants.cs b/database/DevHome.Database/SchemaAccessorConstants.cs new file mode 100644 index 0000000000..218eff4682 --- /dev/null +++ b/database/DevHome.Database/SchemaAccessorConstants.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Database; + +internal static class SchemaAccessorConstants +{ + internal const string SchemaVersionFileName = "SchemaVersion.txt"; +} diff --git a/database/DevHome.Database/Services/DatabaseMigrationService.cs b/database/DevHome.Database/Services/DatabaseMigrationService.cs new file mode 100644 index 0000000000..d2a2cc678b --- /dev/null +++ b/database/DevHome.Database/Services/DatabaseMigrationService.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using DevHome.Common.Helpers; +using DevHome.Common.TelemetryEvents.DevHomeDatabase; +using DevHome.Telemetry; +using Microsoft.EntityFrameworkCore; +using Serilog; +using Windows.Storage; + +namespace DevHome.Database.Services; + +public class DatabaseMigrationService +{ + private readonly ILogger _log = Log.ForContext("SourceContext", nameof(DatabaseMigrationService)); + + private readonly ISchemaAccessFactory _schemaAccessFactory; + + private readonly IDevHomeDatabaseContextFactory _databaseContextFactory; + + private readonly ICustomMigrationHandlerFactory _customMigrationHandlerFactory; + + public DatabaseMigrationService( + ISchemaAccessFactory schemaAccessFactory, + IDevHomeDatabaseContextFactory databaseContextFactory, + ICustomMigrationHandlerFactory customMigrationHandlerFactory) + { + _schemaAccessFactory = schemaAccessFactory; + _databaseContextFactory = databaseContextFactory; + _customMigrationHandlerFactory = customMigrationHandlerFactory; + } + + public bool ShouldMigrateDatabase() + { + var previousVersion = GetPreviousVersion(); + var currentVersion = _databaseContextFactory.GetNewContext().SchemaVersion; + + return previousVersion < currentVersion; + } + + /// + /// Migrates the database to the version stored inside the database context. + /// + /// If the migration was ran more than once per session. + public void MigrateDatabase() + { + // Check here even if ShouldMigrateDatabase was called already. + // Just to be sure. + if (!ShouldMigrateDatabase()) + { + return; + } + + var previousVersion = GetPreviousVersion(); + var currentVersion = _databaseContextFactory.GetNewContext().SchemaVersion; + var migrateDatabaseScript = GetMigrationQuery(previousVersion, currentVersion); + + if (string.IsNullOrEmpty(migrateDatabaseScript)) + { + Log.Warning($"The migration script is empty. Not migrating the database."); + return; + } + + var databaseContext = _databaseContextFactory.GetNewContext(); + try + { + databaseContext.Database.ExecuteSqlRaw(migrateDatabaseScript); + } + catch (Exception ex) + { + Log.Error(ex, $"Could not migrate the database from {previousVersion} to {currentVersion}"); + TelemetryFactory.Get().Log( + "DevHome_DatabaseContext_Event", + LogLevel.Critical, + new DatabaseMigrationErrorEvent(ex, previousVersion, currentVersion)); + return; + } + + try + { + var migrationHandlers = _customMigrationHandlerFactory + .GetCustomMigrationHandlers(previousVersion, currentVersion); + + // Call any custom migrations. + foreach (var migration in migrationHandlers) + { + var shouldContinue = migration.PrepareForMigration(); + + if (shouldContinue) + { + migration.Migrate(); + } + else + { + Log.Warning($"Prepare for {nameof(migration)} returned false. Not running Execute"); + } + } + } + catch (Exception ex) + { + Log.Error(ex, $"Could not migrate the database from {previousVersion} to {currentVersion}"); + TelemetryFactory.Get().Log( + "DevHome_DatabaseContext_Event", + LogLevel.Critical, + new DatabaseMigrationErrorEvent(ex, previousVersion, currentVersion)); + } + + // Migration was successful. + var schemaAccessor = _schemaAccessFactory.GenerateSchemaAccessor(); + schemaAccessor.WriteSchemaVersion(currentVersion); + + var userVersionQuery = $"PRAGMA user_version = {currentVersion}"; + _databaseContextFactory.GetNewContext().Database.ExecuteSqlRaw(userVersionQuery); + } + + /// + /// Reads the migration script and returns the contents. + /// + /// The version stored inside the schema file. + /// The version to migrate to. + /// The contents of the script file. String.Empty if the file does not exist. + public string GetMigrationQuery(uint previousVersion, uint currentVersion) + { + var queryFileLocation = $"Assets/MigrationScripts/{previousVersion}To{currentVersion}.sql"; + if (RuntimeHelper.IsMSIX) + { + Uri uri = new Uri($"ms-appx:///{queryFileLocation}"); + var migrationStorageFile = StorageFile.GetFileFromApplicationUriAsync(uri).AsTask().Result; + if (migrationStorageFile != null) + { + return FileIO.ReadTextAsync(migrationStorageFile).AsTask().Result.ToString(); + } + else + { + Log.Warning($"Cound not find the migration script ms-appx:///{queryFileLocation}"); + return string.Empty; + } + } + else + { + if (File.Exists($"{queryFileLocation}")) + { + return File.ReadAllText($"{queryFileLocation}"); + } + else + { + Log.Warning($"Cound not find the migration script {queryFileLocation}"); + return string.Empty; + } + } + } + + /// + /// Gets the previous datrabase schema. + /// + /// The schema version the database is using. + /// This does dip into the database to get user_version. Will throw an exception + /// if the previous version can't be determined. + private uint GetPreviousVersion() + { + var previousFromSchemaFile = _schemaAccessFactory.GenerateSchemaAccessor().GetPreviousSchemaVersion(); + + var canConnectToTheDatabase = _databaseContextFactory.GetNewContext().Database.CanConnect(); + if (canConnectToTheDatabase) + { + if (previousFromSchemaFile >= 1) + { + return previousFromSchemaFile; + } + else + { + try + { + // The database file exists but no schema was saved. Check user version. + var databaseUserVersion = _databaseContextFactory + .GetNewContext() + .Database + .SqlQueryRaw("PRAGMA user_version") + .AsEnumerable() + .First(); + + if (databaseUserVersion < 0) + { + return 0; + } + + return (uint)databaseUserVersion; + } + catch (Exception ex) + { + _log.Error(ex, $"Could not get user_version from the database."); + TelemetryFactory.Get().Log("DevHome_DatabaseMigration_Event", LogLevel.Critical, new DatabaseMigrationErrorEvent(ex, 0, 0)); + + // Assume the database is up to date. + return _databaseContextFactory.GetNewContext().SchemaVersion; + } + } + } + + // The database does not exist. Migrate from version 0 (a new database) to current. + return 0; + } +} diff --git a/database/DevHome.Database/Services/ICustomMigration.cs b/database/DevHome.Database/Services/ICustomMigration.cs new file mode 100644 index 0000000000..81002ce075 --- /dev/null +++ b/database/DevHome.Database/Services/ICustomMigration.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Database.Services; + +/// +/// Class to handle any custom migrations between versions that can not be done via a Sqlite script. +/// +public interface ICustomMigration +{ + /// + /// If the class has custom logic to move data from previousSchemaVersion to currentSchemaVersion + /// + /// Schema version of the database. + /// Schema version inside DevHome. + /// True if this class has code to run. Otherwise false. + bool CanHandleMigration(uint previousSchemaVersion, uint currentSchemaVersion); + + /// + /// Gets the priority. Priority is used to determine the order classes will run if more than + /// one class can handle the migration. Lowest priority executes first. + /// + uint Priority { get; } + + /// + /// Method to get and save any data for Execute(). + /// + /// True if execute should be called. False if execution should be skipped. + bool PrepareForMigration(); + + /// + /// Performs the migration using data saved from Execute. + /// + void Migrate(); +} diff --git a/database/DevHome.Database/Services/ICustomMigrationHandlerFactory.cs b/database/DevHome.Database/Services/ICustomMigrationHandlerFactory.cs new file mode 100644 index 0000000000..6ded088ec3 --- /dev/null +++ b/database/DevHome.Database/Services/ICustomMigrationHandlerFactory.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace DevHome.Database.Services; + +public interface ICustomMigrationHandlerFactory +{ + IReadOnlyList GetCustomMigrationHandlers(uint previousSchemaVersion, uint currentSchemaVersion); +} diff --git a/database/DevHome.Database/Services/IDevHomeDatabaseContext.cs b/database/DevHome.Database/Services/IDevHomeDatabaseContext.cs new file mode 100644 index 0000000000..075f177e9d --- /dev/null +++ b/database/DevHome.Database/Services/IDevHomeDatabaseContext.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using DevHome.Database.DatabaseModels.RepositoryManagement; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace DevHome.Database.Services; + +public interface IDevHomeDatabaseContext : IDisposable +{ + DbSet Repositories { get; set; } + + uint SchemaVersion { get; } + + DatabaseFacade Database { get; } + + EntityEntry Add(Repository repository); + + int SaveChanges(); +} diff --git a/database/DevHome.Database/Services/IDevHomeDatabaseContextFactory.cs b/database/DevHome.Database/Services/IDevHomeDatabaseContextFactory.cs new file mode 100644 index 0000000000..d6977174c8 --- /dev/null +++ b/database/DevHome.Database/Services/IDevHomeDatabaseContextFactory.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Database.Services; + +public interface IDevHomeDatabaseContextFactory +{ + IDevHomeDatabaseContext GetNewContext(); +} diff --git a/database/DevHome.Database/Services/ISchemaAccessFactory.cs b/database/DevHome.Database/Services/ISchemaAccessFactory.cs new file mode 100644 index 0000000000..434da4d118 --- /dev/null +++ b/database/DevHome.Database/Services/ISchemaAccessFactory.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Database.Services; + +public interface ISchemaAccessFactory +{ + ISchemaAccessor GenerateSchemaAccessor(); +} diff --git a/database/DevHome.Database/Services/ISchemaAccessor.cs b/database/DevHome.Database/Services/ISchemaAccessor.cs new file mode 100644 index 0000000000..778ca078a2 --- /dev/null +++ b/database/DevHome.Database/Services/ISchemaAccessor.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Database.Services; + +/// +/// Use to access the database schema file. +/// +public interface ISchemaAccessor +{ + /// + /// Get the schema version stored in a file. + /// + /// A uint representing the schema version. + uint GetPreviousSchemaVersion(); + + /// + /// Writes schemaVersion to the schema file. + /// + /// The new schema version of the database. + void WriteSchemaVersion(uint schemaVersion); +} diff --git a/database/DevHome.Database/Services/MSIXSchemaAccessor.cs b/database/DevHome.Database/Services/MSIXSchemaAccessor.cs new file mode 100644 index 0000000000..36f979704c --- /dev/null +++ b/database/DevHome.Database/Services/MSIXSchemaAccessor.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using Windows.Storage; + +namespace DevHome.Database.Services; + +public sealed class MSIXSchemaAccessor : ISchemaAccessor +{ + private readonly string _installPath = Windows.ApplicationModel.Package.Current.InstalledPath; + + public uint GetPreviousSchemaVersion() + { + var schemaFileContents = GetPreviousSchema(); + uint schemaVersion; + _ = uint.TryParse(schemaFileContents, out schemaVersion); + + return schemaVersion; + } + + public void WriteSchemaVersion(uint schemaVersion) + { + var assetsFolder = StorageFolder.GetFolderFromPathAsync(_installPath).AsTask().Result; + var schemaVersionFile = assetsFolder.CreateFileAsync(SchemaAccessorConstants.SchemaVersionFileName, CreationCollisionOption.OpenIfExists).AsTask().Result; + + FileIO.WriteTextAsync(schemaVersionFile, schemaVersion.ToString(CultureInfo.InvariantCulture)).AsTask().Wait(); + } + + private string GetPreviousSchema() + { + var storageFile = GetPreviousSchemaFile(); + + try + { + return storageFile == null ? string.Empty : FileIO.ReadTextAsync(storageFile as StorageFile).AsTask().Result; + } + catch + { + return string.Empty; + } + } + + private StorageFile? GetPreviousSchemaFile() + { + try + { + var installPathFolder = StorageFolder.GetFolderFromPathAsync(_installPath).AsTask().Result; + return installPathFolder.CreateFileAsync(SchemaAccessorConstants.SchemaVersionFileName, CreationCollisionOption.OpenIfExists).AsTask().Result; + } + catch + { + return null; + } + } +} diff --git a/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs b/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs index 42d7014a8f..af07631ea1 100644 --- a/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs +++ b/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs @@ -13,31 +13,45 @@ namespace DevHome.Database.Services; +/// +/// Provides actions to CRUD 's. This will update UpdatedUTCDate. +/// public class RepositoryManagementDataAccessService { private const string EventName = "DevHome_RepositoryData_Event"; private readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryManagementDataAccessService)); - private readonly DevHomeDatabaseContextFactory _databaseContextFactory; + private readonly IDevHomeDatabaseContextFactory _databaseContextFactory; - public RepositoryManagementDataAccessService( - DevHomeDatabaseContextFactory databaseContextFactory) + public RepositoryManagementDataAccessService(IDevHomeDatabaseContextFactory databaseContextFactory) { _databaseContextFactory = databaseContextFactory; } /// - /// Makes a new Repository entity with the provided name and location then saves it - /// to the database. + /// Makes a new . /// - /// The new repository. Can return null if the database threw an exception. - public Repository MakeRepository(string repositoryName, string cloneLocation, string repositoryUri) + /// The name of the repository. + /// The full path to the root of the repository. + /// The uri used to clone the repository. + /// The newly made Repository. Null if an exception occured. Can return a repository + /// from the database if it already exists. + public Repository? MakeRepository(string repositoryName, string cloneLocation, string repositoryUri) { return MakeRepository(repositoryName, cloneLocation, string.Empty, repositoryUri); } - public Repository MakeRepository(string repositoryName, string cloneLocation, string configurationFileLocationAndName, string repositoryUri) + /// + /// Makes a new repository and incudes information about the configuration file. + /// + /// The name of the repository. + /// The full path to the root of the repository. + /// Full path, including the file name, of the configuration file. + /// The uri used to clone the repository. + /// The newly made Repository. Null if an exception occured. Can return a repository + /// from the database if it already exists. + public Repository? MakeRepository(string repositoryName, string cloneLocation, string configurationFileLocationAndName, string repositoryUri) { return MakeRepository(repositoryName, cloneLocation, configurationFileLocationAndName, repositoryUri, null); } @@ -90,6 +104,10 @@ public Repository MakeRepository(string repositoryName, string cloneLocation, st return newRepo; } + /// + /// Gets all repositories stored in the database. + /// + /// A list of all repositories found in the database. public List GetRepositories() { _log.Information("Getting repositories"); @@ -112,6 +130,12 @@ public List GetRepositories() return repositories; } + /// + /// Retrives a single from the database. + /// + /// The name of the repository. + /// The full path to the root of the repository. + /// If found, the . Otherwise null. public Repository? GetRepository(string repositoryName, string cloneLocation) { _log.Information("Getting a repository"); @@ -119,6 +143,7 @@ public List GetRepositories() { using var dbContext = _databaseContextFactory.GetNewContext(); #pragma warning disable CA1309 // Use ordinal string comparison + // https://learn.microsoft.com/ef/core/miscellaneous/collations-and-case-sensitivity#translation-of-built-in-net-string-operations return dbContext.Repositories.FirstOrDefault(x => x.RepositoryName!.Equals(repositoryName) && string.Equals(x.RepositoryClonePath, Path.GetFullPath(cloneLocation))); #pragma warning restore CA1309 // Use ordinal string comparison @@ -135,22 +160,17 @@ public List GetRepositories() return null; } + /// + /// Updates the clone location of a + /// + /// The repository to update. + /// The new clone location + /// True if the update was successful. Otherwise false. public bool UpdateCloneLocation(Repository repository, string newLocation) { try { - using var dbContext = _databaseContextFactory.GetNewContext(); - var repositoryToUpdate = dbContext.Repositories.Find(repository.RepositoryId); - if (repositoryToUpdate == null) - { - _log.Warning($"{nameof(UpdateCloneLocation)} was called with a RepositoryId of {repository.RepositoryId} and it does not exist in the database."); - return false; - } - - // Maybe update the tracking information on repository. This way - // EF will catch the change. repository.RepositoryClonePath = newLocation; - repositoryToUpdate.RepositoryClonePath = newLocation; if (repository.HasAConfigurationFile) { @@ -158,9 +178,12 @@ public bool UpdateCloneLocation(Repository repository, string newLocation) var configurationFileName = Path.GetFileName(configurationFolder); repository.ConfigurationFileLocation = Path.Combine(newLocation, configurationFolder ?? string.Empty, configurationFileName ?? string.Empty); - repositoryToUpdate.ConfigurationFileLocation = Path.Combine(newLocation, configurationFolder ?? string.Empty, configurationFileName ?? string.Empty); } + repository.UpdatedUTCDate = DateTime.UtcNow; + + using var dbContext = _databaseContextFactory.GetNewContext(); + dbContext.Repositories.Update(repository); dbContext.SaveChanges(); } catch (Exception ex) @@ -208,21 +231,20 @@ public bool SetSourceControlId(Repository repository, Guid sourceControlId) return true; } + /// + /// Sets the IsHidden property of the . + /// + /// The repository to update. + /// The value to put into the database. public void SetIsHidden(Repository repository, bool isHidden) { try { - using var dbContext = _databaseContextFactory.GetNewContext(); - var repositoryToUpdate = dbContext.Repositories.Find(repository.RepositoryId); - if (repositoryToUpdate == null) - { - _log.Warning($"{nameof(SetIsHidden)} was called with a RepositoryId of {repository.RepositoryId} and it does not exist in the database."); - return; - } - - repositoryToUpdate.IsHidden = isHidden; repository.IsHidden = isHidden; + repository.UpdatedUTCDate = DateTime.UtcNow; + using var dbContext = _databaseContextFactory.GetNewContext(); + dbContext.Repositories.Update(repository); dbContext.SaveChanges(); } catch (Exception ex) @@ -236,19 +258,16 @@ public void SetIsHidden(Repository repository, bool isHidden) } } + /// + /// Removes the from the database. + /// + /// The repository to remove. public void RemoveRepository(Repository repository) { try { using var dbContext = _databaseContextFactory.GetNewContext(); - var repositoryToRemove = dbContext.Repositories.Find(repository.RepositoryId); - if (repositoryToRemove == null) - { - _log.Warning($"{nameof(RemoveRepository)} was called with a RepositoryId of {repository.RepositoryId} and it does not exist in the database."); - return; - } - - dbContext.Repositories.Remove(repositoryToRemove); + dbContext.Repositories.Remove(repository); dbContext.SaveChanges(); } catch (Exception ex) diff --git a/database/DevHome.Database/Services/WinExeSchemaAccessor.cs b/database/DevHome.Database/Services/WinExeSchemaAccessor.cs new file mode 100644 index 0000000000..256fe0fde1 --- /dev/null +++ b/database/DevHome.Database/Services/WinExeSchemaAccessor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.IO; + +namespace DevHome.Database.Services; + +public class WinExeSchemaAccessor : ISchemaAccessor +{ + private readonly string _schemaPath = Path.Join(SchemaAccessorConstants.SchemaVersionFileName); + + public uint GetPreviousSchemaVersion() + { + var previousSchemaContents = GetPreviousSchema(); + uint schemaVersion; + _ = uint.TryParse(previousSchemaContents, out schemaVersion); + + return schemaVersion; + } + + private string GetPreviousSchema() + { + return File.Exists(_schemaPath) ? _schemaPath : string.Empty; + } + + public void WriteSchemaVersion(uint schemaVersion) + { + File.WriteAllText(_schemaPath, schemaVersion.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/docs/extensions/LocalRepository/readme.md b/docs/extensions/LocalRepository/readme.md index 7e6dfff3c0..5571e0df9f 100644 --- a/docs/extensions/LocalRepository/readme.md +++ b/docs/extensions/LocalRepository/readme.md @@ -5,10 +5,10 @@ and see property information displayed after retrieval from a source control tec ## List of Allowed Property Values Displayed in File Explorer -System.VersionControl.Status, -System.VersionControl.LastChangeDate, -System.VersionControl.LastChangeAuthorEmail, -System.VersionControl.LastChangeAuthorName, -System.VersionControl.LastChangeID, -System.VersionControl.LastChangeMessage, -System.VersionControl.CurrentFolderStatus \ No newline at end of file +* System.VersionControl.Status +* System.VersionControl.LastChangeDate +* System.VersionControl.LastChangeAuthorEmail +* System.VersionControl.LastChangeAuthorName +* System.VersionControl.LastChangeID +* System.VersionControl.LastChangeMessage +* System.VersionControl.CurrentFolderStatus \ No newline at end of file diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 3b5a56e6ec..8a86e9c60f 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -13,10 +13,10 @@ using DevHome.Customization.Extensions; using DevHome.Dashboard.Extensions; using DevHome.Database.Extensions; +using DevHome.Database.Services; using DevHome.ExtensionLibrary.Extensions; using DevHome.Helpers; using DevHome.RepositoryManagement.Extensions; -using DevHome.RepositoryManagement.ViewModels; using DevHome.Services; using DevHome.Services.Core.Extensions; using DevHome.Services.DesiredStateConfiguration.Extensions; @@ -84,7 +84,6 @@ private static string RemoveComments(string text) public App() { - // TODO: Add database migration. InitializeComponent(); #if DEBUG_FAILFAST DebugSettings.FailFastOnErrors = true; @@ -190,6 +189,19 @@ public App() UnhandledException += App_UnhandledException; AppInstance.GetCurrent().Activated += OnActivated; + var databaseMigrator = Host.GetService(); + if (databaseMigrator.ShouldMigrateDatabase()) + { + try + { + databaseMigrator.MigrateDatabase(); + } + catch (Exception ex) + { + Log.Error(ex, $"Could not migrate the database"); + } + } + TelemetryFactory.Get().Log("DevHome_Started_Event", LogLevel.Critical, new DevHomeStartedEvent()); Log.Information("Dev Home Started."); } diff --git a/src/NavConfig.jsonc b/src/NavConfig.jsonc index f83e7a23b8..2fdbd25154 100644 --- a/src/NavConfig.jsonc +++ b/src/NavConfig.jsonc @@ -18,6 +18,15 @@ "viewModelFullName": "DevHome.SetupFlow.ViewModels.SetupFlowViewModel", "icon": "f156" }, + { + "experimentalFeatureIdentity": "RepositoryManagementExperiment", + "identity": "DevHome.RepositoryManagement", + "assembly": "DevHome.RepositoryManagement", + "viewFullName": "DevHome.RepositoryManagement.Views.RepositoryManagementMainPageView", + "viewModelFullName": "DevHome.RepositoryManagement.ViewModels.RepositoryManagementMainPageViewModel", + "iconFontFamily": "DevHomeFluentIcons", + "icon": "F03F" + }, { "experimentalFeatureIdentity": "EnvironmentsManagementPage", "identity": "DevHome.Environments", @@ -40,15 +49,6 @@ "viewModelFullName": "DevHome.Utilities.ViewModels.UtilitiesMainPageViewModel", "iconFontFamily": "DevHomeFluentIcons", "icon": "ECED" - }, - { - "experimentalFeatureIdentity": "RepositoryManagementExperiment", - "identity": "DevHome.RepositoryManagement", - "assembly": "DevHome.RepositoryManagement", - "viewFullName": "DevHome.RepositoryManagement.Views.RepositoryManagementMainPageView", - "viewModelFullName": "DevHome.RepositoryManagement.ViewModels.RepositoryManagementMainPageViewModel", - "iconFontFamily": "DevHomeFluentIcons", - "icon": "F03F" } ] } @@ -155,30 +155,6 @@ "key": "DevHome.Utilities.ViewModels.UtilitiesMainPageViewModel" } }, - { - "identity": "RepositoryManagementExperiment", - "enabledByDefault": false, - "buildTypeOverrides": [ - { - "buildType": "dev", - "enabledByDefault": false, - "visible": true - }, - { - "buildType": "canary", - "enabledByDefault": false, - "visible": false - }, - { - "buildType": "stable", - "enabledByDefault": false, - "visible": false - } - ], - "openPage": { - "key": "DevHome.RepositoryManagement.ViewModels.RepositoryManagementMainPageView" - } - }, { "identity": "RepositoryManagementSourceControlSelector", "enabledByDefault": true, diff --git a/test/Database/DatabaseTestHelper.cs b/test/Database/DatabaseTestHelper.cs new file mode 100644 index 0000000000..2f121da1d2 --- /dev/null +++ b/test/Database/DatabaseTestHelper.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Helpers; +using DevHome.Database; +using DevHome.Database.Factories; +using DevHome.Database.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Windows.Storage; + +namespace DevHome.Test.Database; + +internal sealed class DatabaseTestHelper +{ + private const string DatabaseName = "TestDevHomeDatabase.db"; + + public SchemaAccessTester SchemaAccessTester { get; } + + public Mock SchemaAccessorFactory { get; } + + public Mock DatabaseContext { get; } + + public Mock DatabaseContextfactory { get; } + + private string _dbPath = string.Empty; + + internal DatabaseTestHelper() + { + SchemaAccessTester = new SchemaAccessTester(); + + SchemaAccessorFactory = new Mock(); + + DatabaseContext = new Mock(); + + DatabaseContextfactory = new Mock(); + } + + public void SetupTestAssets() + { + if (RuntimeHelper.IsMSIX) + { + _dbPath = Path.Join(ApplicationData.Current.LocalFolder.Path, DatabaseName); + } + else + { + _dbPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), DatabaseName); + } + + RemoveTestAssets(); + SchemaAccessTester.DeleteFile(); + + SchemaAccessorFactory.Setup(x => x.GenerateSchemaAccessor()).Returns(SchemaAccessTester); + DatabaseContext.Setup(x => x.SchemaVersion).Returns(0); + DatabaseContext.Setup(x => x.Database).Returns(new DevHomeDatabaseContext(_dbPath).Database); + + DatabaseContextfactory.Setup(x => x.GetNewContext()).Returns(DatabaseContext.Object); + } + + public void CleanupTestAssets() + { + RemoveTestAssets(); + SchemaAccessTester.DeleteFile(); + } + + public void SetPreviousSchemaVersion(uint newVersion) + { + SchemaAccessTester.Version = newVersion; + SchemaAccessTester.WriteSchemaVersion(newVersion); + } + + public void SetCurrentSchemaVersion(uint newVersion) + { + DatabaseContext.Setup(x => x.SchemaVersion).Returns(newVersion); + } + + public void RemoveTestAssets() + { + var dbContext = new DevHomeDatabaseContext(_dbPath); + + // Reset the database + dbContext.ChangeTracker + .Entries() + .ToList() + .ForEach(e => e.State = EntityState.Detached); + + dbContext.Database.EnsureDeleted(); + } + + public DatabaseMigrationService MakeMigrator() + { + return new DatabaseMigrationService( + SchemaAccessorFactory.Object, + DatabaseContextfactory.Object, + new CustomMigrationHandlerFactory()); + } +} diff --git a/test/Database/MigrationTests.cs b/test/Database/MigrationTests.cs new file mode 100644 index 0000000000..2c1911b929 --- /dev/null +++ b/test/Database/MigrationTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Helpers; +using DevHome.Database; +using DevHome.Database.Factories; +using DevHome.Database.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Windows.Storage; + +namespace DevHome.Test.Database; + +[TestClass] +public class MigrationTests +{ + private readonly DatabaseTestHelper _databaseTestHelper = new(); + + [TestInitialize] + public void SetupTestAssets() + { + _databaseTestHelper.SetupTestAssets(); + } + + [TestCleanup] + public void CleanupTestAssets() + { + _databaseTestHelper.RemoveTestAssets(); + } + + [TestMethod] + [TestCategory("Unit")] + public void TestShouldMigrateDatabase() + { + _databaseTestHelper.DatabaseContextfactory.Object.GetNewContext().Database.EnsureCreated(); + var migrator = _databaseTestHelper.MakeMigrator(); + + // versions are the same. + _databaseTestHelper.SetPreviousSchemaVersion(1); + _databaseTestHelper.SetCurrentSchemaVersion(1); + Assert.IsFalse(migrator.ShouldMigrateDatabase()); + + // Migrate to a new version. + _databaseTestHelper.SetPreviousSchemaVersion(1); + _databaseTestHelper.SetCurrentSchemaVersion(2); + Assert.IsTrue(migrator.ShouldMigrateDatabase()); + + // Migrate to an older version + // Moving to a lower version is no-opt because the old schema version + // should have all the tables as the upgraded version. + // a.k.a no tables were dropped going from v2->v3 + _databaseTestHelper.SetPreviousSchemaVersion(3); + _databaseTestHelper.SetCurrentSchemaVersion(2); + Assert.IsFalse(migrator.ShouldMigrateDatabase()); + + // Test getting previous version from the user_version pragma + _databaseTestHelper.SetPreviousSchemaVersion(0); + _databaseTestHelper.SchemaAccessTester.DeleteFile(); + + // Test with the same version + var userVersionQuery = $"PRAGMA user_version = {2}"; + _databaseTestHelper.DatabaseContextfactory.Object.GetNewContext().Database.ExecuteSqlRaw(userVersionQuery); + Assert.IsFalse(migrator.ShouldMigrateDatabase()); + + // Test with a higher previous version. + userVersionQuery = $"PRAGMA user_version = {3}"; + _databaseTestHelper.DatabaseContextfactory.Object.GetNewContext().Database.ExecuteSqlRaw(userVersionQuery); + Assert.IsFalse(migrator.ShouldMigrateDatabase()); + + // Test with a lower previous version. + userVersionQuery = $"PRAGMA user_version = {1}"; + _databaseTestHelper.DatabaseContextfactory.Object.GetNewContext().Database.ExecuteSqlRaw(userVersionQuery); + Assert.IsTrue(migrator.ShouldMigrateDatabase()); + + // Always migrate if the database file does not exist. + // Migrator will not use the file regardless if it exists. + _databaseTestHelper.RemoveTestAssets(); + _databaseTestHelper.SchemaAccessTester.DeleteFile(); + + // Test same version + _databaseTestHelper.SetPreviousSchemaVersion(1); + _databaseTestHelper.SetCurrentSchemaVersion(1); + Assert.IsTrue(migrator.ShouldMigrateDatabase()); + + // Test a lower previous version + _databaseTestHelper.SetPreviousSchemaVersion(1); + _databaseTestHelper.SetCurrentSchemaVersion(2); + Assert.IsTrue(migrator.ShouldMigrateDatabase()); + + // Test a higher previous version + _databaseTestHelper.SetPreviousSchemaVersion(2); + _databaseTestHelper.SetCurrentSchemaVersion(1); + Assert.IsTrue(migrator.ShouldMigrateDatabase()); + } + + [TestMethod] + [TestCategory("Unit")] + public void TestMigrate0To1() + { + RemoveAndMigrateDatabase(0, 1); + var tableNames = _databaseTestHelper.DatabaseContext.Object.Database + .SqlQueryRaw("SELECT name FROM sqlite_master WHERE type='table'") + .ToList(); + + Assert.IsTrue(tableNames.Any(x => x.Equals("Repository", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + [TestCategory("Unit")] + public void TestMigrateDatabaseWithNonExistentScript() + { + RemoveAndMigrateDatabase(0, 1); + + _databaseTestHelper.SetPreviousSchemaVersion(1); + _databaseTestHelper.SetCurrentSchemaVersion(uint.MaxValue); + _databaseTestHelper.MakeMigrator().MigrateDatabase(); + Assert.AreEqual(1U, _databaseTestHelper.SchemaAccessTester.GetPreviousSchemaVersion()); + } + + private void RemoveAndMigrateDatabase(uint previousVersion, uint currentVersion) + { + _databaseTestHelper.RemoveTestAssets(); + _databaseTestHelper.SetPreviousSchemaVersion(previousVersion); + _databaseTestHelper.SetCurrentSchemaVersion(currentVersion); + _databaseTestHelper.MakeMigrator().MigrateDatabase(); + Assert.IsFalse(_databaseTestHelper.DatabaseContextfactory.Object.GetNewContext().Database.EnsureCreated()); + Assert.AreEqual(currentVersion, _databaseTestHelper.SchemaAccessTester.GetPreviousSchemaVersion()); + } +} diff --git a/test/Database/RepositoryTests.cs b/test/Database/RepositoryTests.cs index d5764ac8db..507861f5dd 100644 --- a/test/Database/RepositoryTests.cs +++ b/test/Database/RepositoryTests.cs @@ -7,7 +7,8 @@ namespace DevHome.Test.Database; -// TODO: Add Database tests. +// TODO: use DatabaseTestHelper after PR 3896 +// https://github.com/microsoft/devhome/pull/3896 is complete. [TestClass] public class RepositoryTests { diff --git a/test/Database/SchemaAccessTester.cs b/test/Database/SchemaAccessTester.cs new file mode 100644 index 0000000000..5c3db1f380 --- /dev/null +++ b/test/Database/SchemaAccessTester.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using DevHome.Database.Services; + +namespace DevHome.Test.Database; + +internal sealed class SchemaAccessTester : ISchemaAccessor +{ + public uint Version { get; set; } + + private readonly string _schemaPath = Path.Join("SchemaVersionForTest.txt"); + + public uint GetPreviousSchemaVersion() + { + var previousSchemaContents = GetPreviousSchema(); + uint schemaVersion; + _ = uint.TryParse(previousSchemaContents, out schemaVersion); + + return schemaVersion; + } + + private string GetPreviousSchema() + { + return File.Exists(_schemaPath) ? File.ReadAllText(_schemaPath) : string.Empty; + } + + public void WriteSchemaVersion(uint schemaVersion) + { + File.WriteAllText(_schemaPath, schemaVersion.ToString(CultureInfo.InvariantCulture)); + } + + public void DeleteFile() + { + if (File.Exists(_schemaPath)) + { + File.Delete(_schemaPath); + } + } +} diff --git a/test/DevHome.Test.csproj b/test/DevHome.Test.csproj index 8a275f28a1..7660834c28 100644 --- a/test/DevHome.Test.csproj +++ b/test/DevHome.Test.csproj @@ -13,6 +13,7 @@ + diff --git a/tools/Customization/DevHome.Customization/DevHome.Customization.csproj b/tools/Customization/DevHome.Customization/DevHome.Customization.csproj index 6e9bd9ccbc..6d1cc23349 100644 --- a/tools/Customization/DevHome.Customization/DevHome.Customization.csproj +++ b/tools/Customization/DevHome.Customization/DevHome.Customization.csproj @@ -29,7 +29,6 @@ - @@ -72,8 +71,4 @@ - - - - \ No newline at end of file diff --git a/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml b/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml deleted file mode 100644 index 6c22bf4474..0000000000 --- a/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - diff --git a/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml.cs b/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml.cs deleted file mode 100644 index c3665cb807..0000000000 --- a/tools/Customization/DevHome.Customization/Views/AddRepositoriesView.xaml.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using DevHome.Common.Extensions; -using DevHome.Common.Services; -using DevHome.Customization.Helpers; -using DevHome.Customization.Models; -using DevHome.Customization.ViewModels; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.Windows.DevHome.SDK; -using Serilog; - -namespace DevHome.Customization.Views; - -public sealed partial class AddRepositoriesView : UserControl -{ - private readonly ILogger _log = Log.ForContext("SourceContext", nameof(AddRepositoriesView)); - - public FileExplorerViewModel ViewModel - { - get; - } - - public AddRepositoriesView() - { - ViewModel = Application.Current.GetService(); - this.InitializeComponent(); - } - - public void RemoveFolderButton_Click(object sender, RoutedEventArgs e) - { - // Extract relevant data from view and give to view model for remove - MenuFlyoutItem menuItem = (MenuFlyoutItem)sender; - if (menuItem.DataContext is RepositoryInformation repoInfo) - { - ViewModel.RemoveTrackedRepositoryFromDevHome(repoInfo.RepositoryRootPath); - } - } - - private void SourceControlProviderMenuFlyout_Opening(object sender, object e) - { - if (sender is MenuFlyout menuFlyout) - { - menuFlyout.Items.Clear(); - - foreach (var extension in ViewModel.ExtensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository).Result) - { - var menuItem = new MenuFlyoutItem - { - Text = extension.ExtensionDisplayName, - Tag = extension, - }; - menuItem.Click += AssignSourceControlProviderButton_Click; - - var stringResource = new StringResource("DevHome.Customization.pri", "DevHome.Customization/Resources"); - ToolTipService.SetToolTip(menuItem, stringResource.GetLocalized("PrefixForDevHomeVersion", extension.PackageDisplayName)); - menuFlyout.Items.Add(menuItem); - } - } - } - - public async void AssignSourceControlProviderButton_Click(object sender, RoutedEventArgs e) - { - // Extract relevant data from view and give to view model for assign - MenuFlyoutItem menuItem = (MenuFlyoutItem)sender; - if (menuItem.DataContext is RepositoryInformation repoInfo) - { - var taskResult = await ViewModel.AssignSourceControlProviderToRepository(menuItem.Tag as IExtensionWrapper, repoInfo.RepositoryRootPath); - if (taskResult?.Result != Helpers.ResultType.Success) - { - _log.Error("Error occurred while assigning source control provider: ", taskResult?.Error, taskResult?.Exception, taskResult?.DiagnosticText, taskResult?.DisplayMessage); - ShowErrorContentDialog(taskResult!, this.XamlRoot); - } - } - } - - public async void ShowErrorContentDialog(SourceControlValidationResult result, XamlRoot xamlRoot) - { - var stringResource = new StringResource("DevHome.Customization.pri", "DevHome.Customization/Resources"); - var errorDialog = new ContentDialog - { - Title = stringResource.GetLocalized("AssignSourceControlErrorDialog_Title"), - Content = result.DisplayMessage, - CloseButtonText = stringResource.GetLocalized("CloseButtonText"), - XamlRoot = xamlRoot, - RequestedTheme = ActualTheme, - }; - _ = await errorDialog.ShowAsync(); - } - - public void OpenFolderInFileExplorer_Click(object sender, RoutedEventArgs e) - { - MenuFlyoutItem? menuItem = sender as MenuFlyoutItem; - if (menuItem?.DataContext is RepositoryInformation repoInfo) - { - try - { - // Open folder in file explorer - System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "explorer.exe", - Arguments = repoInfo.RepositoryRootPath, - }; - - System.Diagnostics.Process.Start(startInfo); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to open folder in file explorer"); - } - } - } -} diff --git a/tools/Customization/DevHome.Customization/Views/FileExplorerPage.xaml b/tools/Customization/DevHome.Customization/Views/FileExplorerPage.xaml index 402da6f32b..26c6b622a6 100644 --- a/tools/Customization/DevHome.Customization/Views/FileExplorerPage.xaml +++ b/tools/Customization/DevHome.Customization/Views/FileExplorerPage.xaml @@ -13,7 +13,6 @@ - diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs index ba52021a34..6e164a2106 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using DevHome.Common.Services; using DevHome.Database.Services; using DevHome.RepositoryManagement.Services; @@ -39,7 +40,11 @@ public RepositoryManagementItemViewModelFactory( _repositoryEnhancerService = repositoryEnhancerService; } - public RepositoryManagementItemViewModel MakeViewModel(string repositoryName, string cloneLocation, bool isHidden) + public RepositoryManagementItemViewModel MakeViewModel( + string repositoryName, + string cloneLocation, + bool isHidden, + Action updateCallback) { var localIsHidden = isHidden; var localRepositoryName = repositoryName; @@ -65,7 +70,8 @@ public RepositoryManagementItemViewModel MakeViewModel(string repositoryName, st _extensionService, _repositoryEnhancerService, localRepositoryName, - localCloneLocation); + localCloneLocation, + updateCallback); newViewModel.IsHiddenFromPage = localIsHidden; diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/RepositoryActionHelper.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/RepositoryActionHelper.cs new file mode 100644 index 0000000000..550dc01e02 --- /dev/null +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/RepositoryActionHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; + +namespace DevHome.RepositoryManagement; + +internal static class RepositoryActionHelper +{ + /// + /// Deleted repositoryRoot and everything under it. + /// + /// The location to delete from. + /// This works even with read-only files. + internal static void DeleteEverything(string repositoryRoot) + { + if (!string.IsNullOrEmpty(repositoryRoot) + && Directory.Exists(repositoryRoot)) + { + // Cumbersome, but needed to remove read-only files. + foreach (var repositoryFile in Directory.EnumerateFiles(repositoryRoot, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(repositoryFile, FileAttributes.Normal); + File.Delete(repositoryFile); + } + + foreach (var repositoryDirectory in Directory.GetDirectories(repositoryRoot, "*", SearchOption.AllDirectories).Reverse()) + { + Directory.Delete(repositoryDirectory); + } + + File.SetAttributes(repositoryRoot, FileAttributes.Normal); + Directory.Delete(repositoryRoot, false); + } + } +} diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs index 4661f88fbe..ad6f895e8d 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs @@ -51,6 +51,13 @@ public List GetAllSourceControlProviders() return _extensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository).Result.ToList(); } + public IExtensionWrapper GetSourceControlProvider(string extensionId) + { + return _extensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository) + .Result + .FirstOrDefault(x => x.ExtensionClassId.Equals(extensionId, StringComparison.OrdinalIgnoreCase)); + } + /// /// Associates a source control provider with a local repository. /// @@ -62,6 +69,11 @@ public async Task MakeRepositoryEnhanced(string repositoryLocation, IExten return await AssignSourceControlToPath(repositoryLocation, sourceControlId); } + public void RemoveTrackedRepository(string repositoryLocation) + { + _sourceControlRegistrar.RemoveTrackedRepositoryFromDevHome(repositoryLocation); + } + public string GetLocalBranchName(string repositoryLocation) { try diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw b/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw index 6d56e7d829..5061615c62 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw @@ -126,8 +126,8 @@ File filter example for chossing where to save a configuration file. - Would you like to delete this repository? - Message asking the user to confirm deleting the repository. + Would you like to delete repository {0} at {1} + {Locked="{0},{1}"} Message asking the user to confirm deleting the repository. {0} is the repository name. {1} is the clone location Deleting a repository means it will be permanently removed in File Explorer and from your PC. @@ -178,11 +178,11 @@ Introduces the sorting combo box. - A to Z + Name: Ascending Repositories are sorted from the beginning of the alphabet to the end. - Z to A + Name: Descending Repositories are sorted from the end of the alphabet to the beginning. @@ -261,4 +261,12 @@ Error assigning source control provider Title of the source control error dialog. + + Loading Repositories + Label when Repotiroy Management is loading repositories. + + + Moving + String to display when the repository is being moved. + \ No newline at end of file diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs index 7c2590c75a..81ef908875 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -26,10 +27,6 @@ namespace DevHome.RepositoryManagement.ViewModels; public partial class RepositoryManagementItemViewModel : ObservableObject { - public const string EventName = "DevHome_RepositorySpecific_Event"; - - public const string ErrorEventName = "DevHome_RepositorySpecificError_Event"; - private readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryManagementItemViewModel)); private readonly Window _window; @@ -44,6 +41,8 @@ public partial class RepositoryManagementItemViewModel : ObservableObject private readonly IExtensionService _extensionService; + private readonly Action _updateCallback; + /// /// Gets the name of the repository. /// @@ -57,9 +56,6 @@ public partial class RepositoryManagementItemViewModel : ObservableObject /// /// Gets or sets the latest commit. Nulls are converted to string.empty. /// - /// - /// TODO: Test values are strings only. - /// public string LatestCommit { get => _latestCommit ?? string.Empty; @@ -100,13 +96,21 @@ public string Branch public string SourceControlExtensionClassId { get; set; } + [ObservableProperty] + private bool _enableAllOperationsExceptRunConfiguartion; + + [ObservableProperty] + private bool _shouldShowMovingRepositoryProgressRing; + [ObservableProperty] private MenuFlyout _allSourceControlProviderNames; [RelayCommand] public void UpdateSourceControlProviderNames() { - AllSourceControlProviderNames.Items.Clear(); + // Making a new MenuFlyout in the constructor throws an empty exception because + // the flyout is made in a non-UI thread. Move the constructor here instead. + AllSourceControlProviderNames = new MenuFlyout(); foreach (var extension in _extensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository).Result.ToList()) { var menuItem = new MenuFlyoutItem @@ -124,43 +128,40 @@ public void UpdateSourceControlProviderNames() } [RelayCommand] - public async Task AssignRepositoryANewSourceControlProvider(IExtensionWrapper extensionWrapper) + public async Task AssignRepositoryANewSourceControlProviderAsync(IExtensionWrapper extensionWrapper) { if (!string.Equals(extensionWrapper.ExtensionClassId, SourceControlExtensionClassId, StringComparison.OrdinalIgnoreCase)) { var result = await _repositoryEnhancerService.ReAssignSourceControl(ClonePath, extensionWrapper); if (result.Result != ResultType.Success) { - ShowErrorContentDialog(result); + ShowErrorContentDialogAsync(result); } else { - var repository = GetRepositoryReportIfNull(nameof(AssignRepositoryANewSourceControlProvider)); + var repository = GetRepositoryReportIfNull(nameof(AssignRepositoryANewSourceControlProviderAsync)); _dataAccess.SetSourceControlId(repository, Guid.Parse(extensionWrapper.ExtensionClassId)); } } } [RelayCommand] - public async Task OpenInFileExplorer() + public async Task OpenInFileExplorerAsync() { - await CheckCloneLocationNotifyUserIfNotFound(); - OpenRepositoryInFileExplorer(RepositoryName, ClonePath, nameof(OpenInFileExplorer)); + await CheckCloneLocationNotifyUserIfNotFoundAsync(); + OpenRepositoryInFileExplorer(RepositoryName, ClonePath, nameof(OpenInFileExplorerAsync)); } [RelayCommand] - public async Task OpenInCMD() + public async Task OpenInCMDAsync() { - await CheckCloneLocationNotifyUserIfNotFound(); - OpenRepositoryinCMD(RepositoryName, ClonePath, nameof(OpenInCMD)); + await CheckCloneLocationNotifyUserIfNotFoundAsync(); + OpenRepositoryinCMD(RepositoryName, ClonePath, nameof(OpenInCMDAsync)); } [RelayCommand] - public async Task MoveRepository() + public async Task MoveRepositoryAsync() { - // This action is not enabled due to a bug in FileExploreGitIntegration. - // FileExplorerGitIntegration holds a lock on a file in this repository and it can not - // be moved. var newLocation = await PickNewLocationForRepositoryAsync(); if (string.IsNullOrEmpty(newLocation)) @@ -175,12 +176,34 @@ public async Task MoveRepository() return; } - var newDirectoryInfo = new DirectoryInfo(Path.Join(newLocation, RepositoryName)); - var currentDirectoryInfo = new DirectoryInfo(Path.GetFullPath(ClonePath)); + var newClonePath = Path.Join(newLocation, RepositoryName); try { - currentDirectoryInfo.MoveTo(newDirectoryInfo.FullName); + EnableAllOperationsExceptRunConfiguartion = false; + ShouldShowMovingRepositoryProgressRing = true; + + await Task.Run(() => + { + // Store all file system entry attributes to restore after the move. + // FileSystem.MoveDirectory removes read-only attribute of files and folders. + Dictionary attributes = new(); + foreach (var repositoryFile in Directory.GetFileSystemEntries(ClonePath, "*", SearchOption.AllDirectories)) + { + var theFullPath = Path.GetFullPath(repositoryFile); + theFullPath = theFullPath.Replace(ClonePath, newClonePath); + attributes.Add(Path.GetFullPath(theFullPath), File.GetAttributes(repositoryFile)); + } + + // Directory.Move does not move across drives. + Microsoft.VisualBasic.FileIO.FileSystem.MoveDirectory(ClonePath, newClonePath); + + foreach (var newFile in Directory.GetFileSystemEntries(newClonePath, "*", SearchOption.AllDirectories)) + { + var fullPathToEntry = Path.GetFullPath(newFile); + File.SetAttributes(fullPathToEntry, attributes[Path.GetFullPath(fullPathToEntry)]); + } + }); } catch (Exception ex) { @@ -188,109 +211,49 @@ public async Task MoveRepository() TelemetryFactory.Get().Log( "DevHome_RepositoryLineItem_Event", LogLevel.Critical, - new RepositoryLineItemEvent(nameof(MoveRepository), RepositoryName)); + new RepositoryLineItemEvent(nameof(MoveRepositoryAsync))); } - var repository = GetRepositoryReportIfNull(nameof(MoveRepository)); + var repository = GetRepositoryReportIfNull(nameof(MoveRepositoryAsync)); if (repository == null) { return; } - var didUpdate = _dataAccess.UpdateCloneLocation(repository, newDirectoryInfo.FullName); + var didUpdate = _dataAccess.UpdateCloneLocation(repository, newClonePath); if (!didUpdate) { _log.Error($"Could not update the database. Check logs"); } - ClonePath = Path.Join(newLocation, RepositoryName); - } - - [RelayCommand] - public async Task DeleteRepositoryAsync() - { - // TODO: Add repository name and the location to the dialog. - // TODO: Ask user to type in the repository name before removing. - // This action is not enabled due to a bug in FileExploreGitIntegration. - // FileExplorerGitIntegration holds a lock on a file in this repository and it can not - // be moved. - var cantFindRepositoryDialog = new ContentDialog() - { - XamlRoot = _window.Content.XamlRoot, - Title = $"Would you like to delete this repository?", - Content = $"Deleting a repository means it will be permanently removed in File Explorer and from your PC.", - PrimaryButtonText = "Yes", - CloseButtonText = "Cancel", - }; - - ContentDialogResult dialogResult = ContentDialogResult.None; + _repositoryEnhancerService.RemoveTrackedRepository(ClonePath); + await _repositoryEnhancerService.MakeRepositoryEnhanced(newClonePath, _repositoryEnhancerService.GetSourceControlProvider(SourceControlExtensionClassId)); try { - dialogResult = await cantFindRepositoryDialog.ShowAsync(); + RepositoryActionHelper.DeleteEverything(ClonePath); } catch (Exception ex) { - _log.Error(ex, $"Failed to open confirmation dialog."); - TelemetryFactory.Get().Log( - "DevHome_RepositoryLineItem_Event", - LogLevel.Critical, - new RepositoryLineItemEvent(nameof(DeleteRepositoryAsync), RepositoryName)); - } - - if (dialogResult != ContentDialogResult.Primary) - { - return; + _log.Error(ex, $"Could not remove all of {RepositoryName} from {ClonePath}."); } - try - { - // Remove the repository. - // TODO: Check if this location is a repository and the name matches the repo name - // in path. - if (!string.IsNullOrEmpty(ClonePath) - && Directory.Exists(ClonePath)) - { - // Cumbersome, but needed to remove read-only files. - foreach (var repositoryFile in Directory.EnumerateFiles(ClonePath, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(repositoryFile, FileAttributes.Normal); - File.Delete(repositoryFile); - } - - foreach (var repositoryDirectory in Directory.GetDirectories(ClonePath, "*", SearchOption.AllDirectories).Reverse()) - { - Directory.Delete(repositoryDirectory); - } + ClonePath = newClonePath; - File.SetAttributes(ClonePath, FileAttributes.Normal); - Directory.Delete(ClonePath, false); - } - - var repository = GetRepositoryReportIfNull(nameof(DeleteRepositoryAsync)); - if (repository == null) - { - // Do not warn the user here. If the repository is not in the database - // the repository management page will not display the repository - // when entities are fetched. - return; - } + ShouldShowMovingRepositoryProgressRing = false; + EnableAllOperationsExceptRunConfiguartion = true; + } - _dataAccess.RemoveRepository(repository); - } - catch (Exception ex) - { - _log.Error(ex, $"Error when deleting the repository."); - TelemetryFactory.Get().Log( - "DevHome_RepositoryLineItem_Event", - LogLevel.Critical, - new RepositoryLineItemEvent(nameof(DeleteRepositoryAsync), RepositoryName)); - } + [RelayCommand] + public void HideRepository() + { + RemoveThisRepositoryFromTheList(); + _updateCallback(); } [RelayCommand] - public async Task MakeConfigurationFileWithThisRepository() + public async Task MakeConfigurationFileWithThisRepositoryAsync() { try { @@ -304,7 +267,7 @@ public async Task MakeConfigurationFileWithThisRepository() if (!string.IsNullOrEmpty(fileName)) { var repositoryToUse = _dataAccess.GetRepository(RepositoryName, ClonePath); - var repository = GetRepositoryReportIfNull(nameof(MakeConfigurationFileWithThisRepository)); + var repository = GetRepositoryReportIfNull(nameof(MakeConfigurationFileWithThisRepositoryAsync)); if (repository == null) { return; @@ -340,6 +303,7 @@ public void RunConfigurationFile() processStartInfo.UseShellExecute = true; processStartInfo.FileName = "winget"; processStartInfo.ArgumentList.Add("configure"); + processStartInfo.ArgumentList.Add(configurationFileLocation); processStartInfo.Verb = "RunAs"; StartProcess(processStartInfo, nameof(RunConfigurationFile)); @@ -352,7 +316,8 @@ internal RepositoryManagementItemViewModel( IExtensionService extensionService, RepositoryEnhancerService repositoryEnhancerService, string repositoryName, - string cloneLocation) + string cloneLocation, + Action updateCallback) { _window = window; _dataAccess = dataAccess; @@ -360,8 +325,9 @@ internal RepositoryManagementItemViewModel( RepositoryName = repositoryName; _clonePath = cloneLocation; _extensionService = extensionService; - _allSourceControlProviderNames = new MenuFlyout(); _repositoryEnhancerService = repositoryEnhancerService; + _updateCallback = updateCallback; + _enableAllOperationsExceptRunConfiguartion = true; } public void RemoveThisRepositoryFromTheList() @@ -373,6 +339,7 @@ public void RemoveThisRepositoryFromTheList() } _dataAccess.SetIsHidden(repository, true); + IsHiddenFromPage = true; } private void OpenRepositoryInFileExplorer(string repositoryName, string cloneLocation, string action) @@ -381,7 +348,7 @@ private void OpenRepositoryInFileExplorer(string repositoryName, string cloneLoc TelemetryFactory.Get().Log( "DevHome_RepositoryLineItem_Event", LogLevel.Critical, - new RepositoryLineItemEvent(action, repositoryName)); + new RepositoryLineItemEvent(action)); var processStartInfo = new ProcessStartInfo { @@ -401,7 +368,7 @@ private void OpenRepositoryinCMD(string repositoryName, string cloneLocation, st TelemetryFactory.Get().Log( "DevHome_RepositoryLineItem_Event", LogLevel.Critical, - new RepositoryLineItemEvent(action, repositoryName)); + new RepositoryLineItemEvent(action)); var processStartInfo = new ProcessStartInfo { @@ -420,7 +387,6 @@ private void StartProcess(ProcessStartInfo processStartInfo, string operation) { try { - // TODO: read stdout/stderror for errors in execution. Process.Start(processStartInfo); } catch (Exception e) @@ -463,12 +429,12 @@ private void SendTelemetryAndLogError(string operation, Exception ex) TelemetryFactory.Get().LogError( "DevHome_RepositoryLineItemError_Event", LogLevel.Critical, - new RepositoryLineItemErrorEvent(operation, ex.HResult, ex.Message, RepositoryName)); + new RepositoryLineItemErrorEvent(operation, ex)); _log.Error(ex, string.Empty); } - private async Task ShowCloneLocationNotFoundDialogAsync() + private async Task CloneLocationNotFoundNotifyUserAsync() { // strings need to be localized var cantFindRepositoryDialog = new ContentDialog() @@ -493,7 +459,7 @@ private async Task ShowCloneLocationNotFoundDialogAsync() } catch (Exception ex) { - SendTelemetryAndLogError(nameof(ShowCloneLocationNotFoundDialogAsync), ex); + SendTelemetryAndLogError(nameof(CloneLocationNotFoundNotifyUserAsync), ex); } // User will show DevHome where the repository is. @@ -509,7 +475,7 @@ private async Task ShowCloneLocationNotFoundDialogAsync() return; } - var repository = GetRepositoryReportIfNull(nameof(ShowCloneLocationNotFoundDialogAsync)); + var repository = GetRepositoryReportIfNull(nameof(CloneLocationNotFoundNotifyUserAsync)); if (repository == null) { @@ -528,6 +494,7 @@ private async Task ShowCloneLocationNotFoundDialogAsync() else if (dialogResult == ContentDialogResult.Secondary) { RemoveThisRepositoryFromTheList(); + _updateCallback(); return; } } @@ -535,17 +502,13 @@ private async Task ShowCloneLocationNotFoundDialogAsync() private Repository GetRepositoryReportIfNull(string action) { var repository = _dataAccess.GetRepository(RepositoryName, ClonePath); - - // The user clicked on this menu from the repository management page. - // The repository should be in the database. - // Somehow getting the repository returned null. if (repository is null) { _log.Warning($"The repository with name {RepositoryName} and clone location {ClonePath} is not in the database when it is expected to be there."); TelemetryFactory.Get().Log( "DevHome_RepositoryLineItem_Event", LogLevel.Critical, - new RepositoryLineItemEvent(action, RepositoryName)); + new RepositoryLineItemEvent(action)); return null; } @@ -553,16 +516,16 @@ private Repository GetRepositoryReportIfNull(string action) return repository; } - private async Task CheckCloneLocationNotifyUserIfNotFound() + private async Task CheckCloneLocationNotifyUserIfNotFoundAsync() { if (!Directory.Exists(Path.GetFullPath(ClonePath))) { // Ask the user if they can point DevHome to the correct location - await ShowCloneLocationNotFoundDialogAsync(); + await CloneLocationNotFoundNotifyUserAsync(); } } - public async void ShowErrorContentDialog(SourceControlValidationResult result) + public async void ShowErrorContentDialogAsync(SourceControlValidationResult result) { var errorDialog = new ContentDialog { diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs index 947dd12fb5..f6c90a5f25 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.Eventing.Reader; using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DevHome.Common.Services; +using DevHome.Common.TelemetryEvents.RepositoryManagement; using DevHome.Common.Windows.FileDialog; using DevHome.Database.DatabaseModels.RepositoryManagement; using DevHome.Database.Services; @@ -18,7 +18,7 @@ using DevHome.RepositoryManagement.Models; using DevHome.RepositoryManagement.Services; using DevHome.SetupFlow.Common.Helpers; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using DevHome.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Serilog; @@ -63,11 +63,14 @@ public partial class RepositoryManagementMainPageViewModel : ObservableObject private string _filterText = string.Empty; [ObservableProperty] - private bool _areFilterAndSortEnabled; + private bool _areControlsEnabled; [ObservableProperty] private bool _shouldShowSourceControlSelection; + [ObservableProperty] + private bool _isNavigatedTo; + public enum SortOrder { NameAscending, @@ -101,11 +104,11 @@ public void ChangeSortOrder(ComboBoxItem selectedItem) _sortTag = SortOrder.NameDescending; } - AreFilterAndSortEnabled = false; + AreControlsEnabled = false; UpdateDisplayedRepositories(); - AreFilterAndSortEnabled = true; + AreControlsEnabled = true; } [RelayCommand] @@ -114,15 +117,37 @@ public void FilterRepositories() UpdateDisplayedRepositories(); } + /// + /// Adds an repository on the computer to the database. + /// + /// An awitable Task + /// If the repository is already in the database "IsHidden" is set to false. [RelayCommand] public async Task AddExistingRepository() { - AreFilterAndSortEnabled = false; + AreControlsEnabled = false; var existingRepositoryLocation = await GetRepositoryLocationFromUser(); if (existingRepositoryLocation.Equals(string.Empty, StringComparison.OrdinalIgnoreCase)) { _log.Warning($"Repository in {nameof(AddExistingRepository)} is either empty."); + AreControlsEnabled = true; + return; + } + + var repositoryName = Path.GetFileName(existingRepositoryLocation); + var maybeExistingRepository = _dataAccessService.GetRepository(repositoryName, existingRepositoryLocation); + + if (maybeExistingRepository != null) + { + _dataAccessService.SetIsHidden(maybeExistingRepository, false); + var existingRepository = _allLineItems.FirstOrDefault(x => x.ClonePath.Equals(existingRepositoryLocation, StringComparison.OrdinalIgnoreCase) + && x.RepositoryName.Equals(repositoryName, StringComparison.OrdinalIgnoreCase)); + + existingRepository.IsHiddenFromPage = false; + + UpdateDisplayedRepositories(); + AreControlsEnabled = true; return; } @@ -149,7 +174,7 @@ public async Task AddExistingRepository() var repositoryUrl = _enhanceRepositoryService.GetRepositoryUrl(existingRepositoryLocation); var configurationFileLocation = DscHelpers.GetConfigurationFileIfExists(existingRepositoryLocation); var newRepository = _dataAccessService.MakeRepository( - Path.GetFileName(existingRepositoryLocation), + repositoryName, existingRepositoryLocation, configurationFileLocation, repositoryUrl, @@ -171,7 +196,7 @@ public async Task AddExistingRepository() UpdateDisplayedRepositories(); - AreFilterAndSortEnabled = true; + AreControlsEnabled = true; } [RelayCommand] @@ -183,39 +208,131 @@ public void NavigateToCloneRepositoryExpirence() [RelayCommand] public async Task LoadRepositories() { - // TODO: Spinning progress ring when loading repositories. - AreFilterAndSortEnabled = false; - _allRepositoriesFromTheDatabase = _dataAccessService.GetRepositories(); - _allRepositoriesFromTheDatabase = await AssignSourceControlId(_allRepositoriesFromTheDatabase); + IsNavigatedTo = true; + AreControlsEnabled = false; - var repositoriesWithCommits = GetRepositoryAndLatestCommitPairs(_allRepositoriesFromTheDatabase); + var tempLineItemsToDisplay = new List(); + await Task.Run(async () => + { + _allRepositoriesFromTheDatabase = _dataAccessService.GetRepositories(); + _allRepositoriesFromTheDatabase = await AssignSourceControlId(_allRepositoriesFromTheDatabase); - _allLineItems.Clear(); - LineItemsToDisplay.Clear(); + var repositoriesWithCommits = GetRepositoryAndLatestCommitPairs(_allRepositoriesFromTheDatabase); - _allLineItems = ConvertToLineItems(repositoriesWithCommits); - LineItemsToDisplay = new(HideFilterAndSort(_allLineItems).Where(x => x.IsHiddenFromPage == false).ToList()); + _allLineItems.Clear(); + _allLineItems = ConvertToLineItems(repositoriesWithCommits); + tempLineItemsToDisplay = HideFilterAndSort(_allLineItems).Where(x => x.IsHiddenFromPage == false).ToList(); + }); ShouldShowSourceControlSelection = _experimentationService.IsFeatureEnabled("RepositoryManagementSourceControlSelector"); - AreFilterAndSortEnabled = true; + LineItemsToDisplay.Clear(); + LineItemsToDisplay = new(tempLineItemsToDisplay); + + AreControlsEnabled = true; + IsNavigatedTo = false; } + /// + /// Removes the repository from the PC. + /// + /// The line item acted upon. + /// An awaitable Task + /// Even with an update callback this method can't update _allLineItems. + /// This means a call to UpdateDisplayedRepositories won't change the UI because the line item + /// isn't removed from _lineItemsToDisplay. [RelayCommand] - public void HideRepository(RepositoryManagementItemViewModel repository) + public async Task DeleteRepositoryAsync(RepositoryManagementItemViewModel repositoryLineItem) { - if (repository == null) + var repositoryName = repositoryLineItem.RepositoryName; + var clonePath = repositoryLineItem.ClonePath; + var deleteRepositoryConfirmationDialog = new ContentDialog() { - return; + XamlRoot = _window.Content.XamlRoot, + Title = _stringResource.GetLocalized("DeleteRepositoryDialogTitle", repositoryName, clonePath), + Content = _stringResource.GetLocalized("DeleteRepositoryDialogContent"), + PrimaryButtonText = _stringResource.GetLocalized("Yes"), + CloseButtonText = _stringResource.GetLocalized("Cancel"), + }; + + ContentDialogResult dialogResult = ContentDialogResult.None; + + try + { + dialogResult = await deleteRepositoryConfirmationDialog.ShowAsync(); } + catch (Exception ex) + { + _log.Error(ex, $"Failed to open delete confirmation dialog."); - AreFilterAndSortEnabled = false; + // Keep LineItemErrorEvent because the event did come from the line item. + TelemetryFactory.Get().Log( + "DevHome_RepositoryLineItem_Event", + LogLevel.Critical, + new RepositoryLineItemErrorEvent(nameof(DeleteRepositoryAsync), ex)); + } + + if (dialogResult != ContentDialogResult.Primary) + { + return; + } - repository.RemoveThisRepositoryFromTheList(); - repository.IsHiddenFromPage = true; + AreControlsEnabled = false; + _allLineItems.Remove(repositoryLineItem); UpdateDisplayedRepositories(); + AreControlsEnabled = true; - AreFilterAndSortEnabled = true; + try + { + var repository = _dataAccessService.GetRepository(repositoryName, clonePath); + if (repository is null) + { + _log.Warning($"The repository with name {repositoryName} and clone location {clonePath} is not in the database when it is expected to be there."); + TelemetryFactory.Get().Log( + "DevHome_RepositoryLineItem_Event", + LogLevel.Critical, + new RepositoryLineItemEvent(nameof(DeleteRepositoryAsync))); + } + else + { + _dataAccessService.RemoveRepository(repository); + } + } + catch (Exception ex) + { + // Fall through to removing files and folders. + _log.Error(ex, $"Error when removing the repository from the database."); + TelemetryFactory.Get().Log( + "DevHome_RepositoryLineItem_Event", + LogLevel.Critical, + new RepositoryLineItemErrorEvent(nameof(DeleteRepositoryAsync), ex)); + } + + try + { + _enhanceRepositoryService.RemoveTrackedRepository(clonePath); + } + catch (Exception ex) + { + _log.Error(ex, $"Error when removing the repository from tracking."); + TelemetryFactory.Get().Log( + "DevHome_RepositoryLineItem_Event", + LogLevel.Critical, + new RepositoryLineItemErrorEvent(nameof(DeleteRepositoryAsync), ex)); + } + + try + { + await Task.Run(() => RepositoryActionHelper.DeleteEverything(clonePath)); + } + catch (Exception ex) + { + _log.Error(ex, $"Error when deleting the repository."); + TelemetryFactory.Get().Log( + "DevHome_RepositoryLineItem_Event", + LogLevel.Critical, + new RepositoryLineItemErrorEvent(nameof(DeleteRepositoryAsync), ex)); + } } public RepositoryManagementMainPageViewModel( @@ -235,6 +352,10 @@ public RepositoryManagementMainPageViewModel( _experimentationService = experimentationService; } + /// + /// Updates Repository Management UI. + /// + /// For the change to appear, make sure modifications are done to the view model. private void UpdateDisplayedRepositories() { LineItemsToDisplay.Clear(); @@ -251,7 +372,12 @@ private List ConvertToLineItems(List<(Reposit foreach (var repositoryWithCommit in repositories) { var repository = repositoryWithCommit.Item1; - var lineItem = _factory.MakeViewModel(repository.RepositoryName, repository.RepositoryClonePath, repository.IsHidden); + var lineItem = _factory.MakeViewModel( + repository.RepositoryName, + repository.RepositoryClonePath, + repository.IsHidden, + UpdateDisplayedRepositories); + lineItem.Branch = _enhanceRepositoryService.GetLocalBranchName(repository.RepositoryClonePath); var commit = repositoryWithCommit.Item2; diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml index 059b5ef8f5..9842832faf 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml @@ -4,12 +4,10 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:behaviors="using:DevHome.Common.Behaviors" - xmlns:commonCustomControls="using:DevHome.Common.Environments.CustomControls" xmlns:commonviews="using:DevHome.Common.Views" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:ic="using:Microsoft.Xaml.Interactions.Core" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:local="using:DevHome.RepositoryManagement.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewmodels="using:DevHome.RepositoryManagement.ViewModels" behaviors:NavigationViewHeaderBehavior.HeaderMode="Never" @@ -27,11 +25,9 @@ - - - - + + - + + + + + + + + + + - diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs index f27d0fbd8d..c1f20c5848 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs @@ -5,8 +5,6 @@ using DevHome.Common.Views; using DevHome.RepositoryManagement.ViewModels; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.Windows.DevHome.SDK; namespace DevHome.RepositoryManagement.Views; diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs index e6c4a69f94..ea7999f730 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs @@ -283,14 +283,17 @@ IAsyncOperation ISetupTask.Execute() _summaryScreenInformation.OwningAccount = RepositoryToClone.OwningAccountName ?? string.Empty; } - var experimentationService = _host.GetService(); - var canUseTheDatabase = experimentationService.IsFeatureEnabled("RepositoryManagementExperiment"); - - if (canUseTheDatabase) + try + { + _ = _dataAccessService.MakeRepository(RepositoryName, CloneLocation.FullName, _summaryScreenInformation.FilePathAndName, RepositoryToClone.RepoUri.ToString()); + } + catch (Exception ex) { - // TODO: Is this the best place to add the repository to the database? - // Maybe a "PostExecutionStep" would be nice. - var repository = _dataAccessService.MakeRepository(RepositoryName, CloneLocation.FullName, _summaryScreenInformation.FilePathAndName, RepositoryToClone.RepoUri.ToString()); + _log.Error(ex, $"Could not add {RepositoryName} to the database because {ex.Message}"); + TelemetryFactory.Get().LogError( + "CloneTask_CouldNotClone_Event", + LogLevel.Critical, + new ExceptionEvent(ex.HResult, ex.Message)); } WasCloningSuccessful = true;