diff --git a/src/GitHubExtension/DataManager/GitHubDataManager.cs b/src/GitHubExtension/DataManager/GitHubDataManager.cs index d86299c1..0df7a61e 100644 --- a/src/GitHubExtension/DataManager/GitHubDataManager.cs +++ b/src/GitHubExtension/DataManager/GitHubDataManager.cs @@ -18,6 +18,7 @@ public partial class GitHubDataManager : IGitHubDataManager, IDisposable private static readonly TimeSpan SearchRetentionTime = TimeSpan.FromDays(7); private static readonly TimeSpan PullRequestStaleTime = TimeSpan.FromDays(1); private static readonly TimeSpan ReviewStaleTime = TimeSpan.FromDays(7); + private static readonly TimeSpan ReleaseRetentionTime = TimeSpan.FromDays(7); // It is possible different widgets have queries which touch the same pull requests. // We want to keep this window large enough that we don't delete data being used by @@ -179,6 +180,28 @@ public async Task UpdatePullRequestsForLoggedInDeveloperIdsAsync() SendDeveloperUpdateEvent(this); } + public async Task UpdateReleasesForRepositoryAsync(string owner, string name, RequestOptions? options = null) + { + ValidateDataStore(); + var parameters = new DataStoreOperationParameters + { + Owner = owner, + RepositoryName = name, + RequestOptions = options, + OperationName = "UpdateReleasesForRepositoryAsync", + }; + + await UpdateDataForRepositoryAsync( + parameters, + async (parameters, devId) => + { + var repository = await UpdateRepositoryAsync(parameters.Owner!, parameters.RepositoryName!, devId.GitHubClient); + await UpdateReleasesAsync(repository, devId.GitHubClient, parameters.RequestOptions); + }); + + SendRepositoryUpdateEvent(this, GetFullNameFromOwnerAndRepository(owner, name), new string[] { "Releases" }); + } + public IEnumerable GetRepositories() { ValidateDataStore(); @@ -703,6 +726,41 @@ private async Task UpdateIssuesAsync(Repository repository, Octokit.GitHubClient Issue.DeleteLastObservedBefore(DataStore, repository.Id, DateTime.UtcNow - LastObservedDeleteSpan); } + // Internal method to update releases. Assumes Repository has already been populated and created. + // DataStore transaction is assumed to be wrapped around this in the public method. + private async Task UpdateReleasesAsync(Repository repository, Octokit.GitHubClient? client = null, RequestOptions? options = null) + { + options ??= RequestOptions.RequestOptionsDefault(); + + // Limit the number of fetched releases. + options.ApiOptions.PageCount = 1; + options.ApiOptions.PageSize = 10; + + client ??= await GitHubClientProvider.Instance.GetClientForLoggedInDeveloper(true); + Log.Logger()?.ReportInfo(Name, $"Updating releases for: {repository.FullName}"); + + var releasesResult = await client.Repository.Release.GetAll(repository.InternalId, options.ApiOptions); + if (releasesResult == null) + { + Log.Logger()?.ReportDebug($"No releases found."); + return; + } + + Log.Logger()?.ReportDebug(Name, $"Results contain {releasesResult.Count} releases."); + foreach (var release in releasesResult) + { + if (release.Draft) + { + continue; + } + + _ = Release.GetOrCreateByOctokitRelease(DataStore, release, repository); + } + + // Remove releases from this repository that were not observed recently. + Release.DeleteLastObservedBefore(DataStore, repository.Id, DateTime.UtcNow - LastObservedDeleteSpan); + } + // Removes unused data from the datastore. private void PruneObsoleteData() { @@ -714,6 +772,7 @@ private void PruneObsoleteData() Search.DeleteBefore(DataStore, DateTime.Now - SearchRetentionTime); SearchIssue.DeleteUnreferenced(DataStore); Review.DeleteUnreferenced(DataStore); + Release.DeleteBefore(DataStore, DateTime.Now - ReleaseRetentionTime); } // Sets a last-updated in the MetaData. diff --git a/src/GitHubExtension/DataManager/IGitHubDataManager.cs b/src/GitHubExtension/DataManager/IGitHubDataManager.cs index a7f6e21e..c6c981c7 100644 --- a/src/GitHubExtension/DataManager/IGitHubDataManager.cs +++ b/src/GitHubExtension/DataManager/IGitHubDataManager.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using GitHubExtension.DataModel; @@ -25,6 +25,8 @@ public interface IGitHubDataManager : IDisposable Task UpdatePullRequestsForLoggedInDeveloperIdsAsync(); + Task UpdateReleasesForRepositoryAsync(string owner, string name, RequestOptions? options = null); + IEnumerable GetRepositories(); IEnumerable GetDeveloperUsers(); diff --git a/src/GitHubExtension/DataModel/DataObjects/Release.cs b/src/GitHubExtension/DataModel/DataObjects/Release.cs new file mode 100644 index 00000000..55eca59e --- /dev/null +++ b/src/GitHubExtension/DataModel/DataObjects/Release.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Dapper; +using Dapper.Contrib.Extensions; +using GitHubExtension.Helpers; + +namespace GitHubExtension.DataModel; + +[Table("Release")] +public class Release +{ + [Key] + public long Id { get; set; } = DataStore.NoForeignKey; + + public long InternalId { get; set; } = DataStore.NoForeignKey; + + // Repository table + public long RepositoryId { get; set; } = DataStore.NoForeignKey; + + public string Name { get; set; } = string.Empty; + + public string TagName { get; set; } = string.Empty; + + public long Prerelease { get; set; } = DataStore.NoForeignKey; + + public string HtmlUrl { get; set; } = string.Empty; + + public long TimeCreated { get; set; } = DataStore.NoForeignKey; + + public long TimePublished { get; set; } = DataStore.NoForeignKey; + + public long TimeLastObserved { get; set; } = DataStore.NoForeignKey; + + [Write(false)] + private DataStore? DataStore + { + get; set; + } + + [Write(false)] + [Computed] + public DateTime CreatedAt => TimeCreated.ToDateTime(); + + [Write(false)] + [Computed] + public DateTime? PublishedAt => TimePublished != 0 ? TimePublished.ToDateTime() : null; + + [Write(false)] + [Computed] + public DateTime LastObservedAt => TimeLastObserved.ToDateTime(); + + public override string ToString() => Name; + + public static Release GetOrCreateByOctokitRelease(DataStore dataStore, Octokit.Release okitRelease, Repository repository) + { + var release = CreateFromOctokitRelease(dataStore, okitRelease, repository); + return AddOrUpdateRelease(dataStore, release); + } + + public static IEnumerable GetAllForRepository(DataStore dataStore, Repository repository) + { + var sql = $"SELECT * FROM Release WHERE RepositoryId = @RepositoryId ORDER BY TimePublished DESC;"; + var param = new + { + RepositoryId = repository.Id, + }; + + Log.Logger()?.ReportDebug(DataStore.GetSqlLogMessage(sql, param)); + var releases = dataStore.Connection!.Query(sql, param, null) ?? Enumerable.Empty(); + foreach (var release in releases) + { + release.DataStore = dataStore; + } + + return releases; + } + + public static Release? GetByInternalId(DataStore dataStore, long internalId) + { + var sql = $"SELECT * FROM Release WHERE InternalId = @InternalId;"; + var param = new + { + InternalId = internalId, + }; + + var release = dataStore.Connection!.QueryFirstOrDefault(sql, param, null); + if (release is not null) + { + // Add Datastore so this object can make internal queries. + release.DataStore = dataStore; + } + + return release; + } + + public static void DeleteLastObservedBefore(DataStore dataStore, long repositoryId, DateTime date) + { + // Delete releases older than the time specified for the given repository. + // This is intended to be run after updating a repository's releases so that non-observed + // records will be removed. + var sql = @"DELETE FROM Release WHERE RepositoryId = $RepositoryId AND TimeLastObserved < $Time;"; + var command = dataStore.Connection!.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$Time", date.ToDataStoreInteger()); + command.Parameters.AddWithValue("$RepositoryId", repositoryId); + Log.Logger()?.ReportDebug(DataStore.GetCommandLogMessage(sql, command)); + var rowsDeleted = command.ExecuteNonQuery(); + Log.Logger()?.ReportDebug(DataStore.GetDeletedLogMessage(rowsDeleted)); + } + + private static Release CreateFromOctokitRelease(DataStore dataStore, Octokit.Release okitRelease, Repository repository) + { + var release = new Release + { + DataStore = dataStore, + InternalId = okitRelease.Id, + RepositoryId = repository.Id, + Name = okitRelease.Name, + TagName = okitRelease.TagName, + Prerelease = okitRelease.Prerelease ? 1 : 0, + HtmlUrl = okitRelease.HtmlUrl, + TimeCreated = okitRelease.CreatedAt.DateTime.ToDataStoreInteger(), + TimePublished = okitRelease.PublishedAt.HasValue ? okitRelease.PublishedAt.Value.DateTime.ToDataStoreInteger() : 0, + TimeLastObserved = DateTime.UtcNow.ToDataStoreInteger(), + }; + + return release; + } + + private static Release AddOrUpdateRelease(DataStore dataStore, Release release) + { + // Check for existing release data. + var existing = GetByInternalId(dataStore, release.InternalId); + if (existing is not null) + { + // Existing releases must be updated and always marked observed. + release.Id = existing.Id; + dataStore.Connection!.Update(release); + release.DataStore = dataStore; + return release; + } + + // No existing release, add it. + release.Id = dataStore.Connection!.Insert(release); + release.DataStore = dataStore; + return release; + } + + public static void DeleteBefore(DataStore dataStore, DateTime date) + { + // Delete releases older than the date listed. + var sql = @"DELETE FROM Release WHERE TimeLastObserved < $Time;"; + var command = dataStore.Connection!.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$Time", date.ToDataStoreInteger()); + Log.Logger()?.ReportDebug(DataStore.GetCommandLogMessage(sql, command)); + var rowsDeleted = command.ExecuteNonQuery(); + Log.Logger()?.ReportDebug(DataStore.GetDeletedLogMessage(rowsDeleted)); + } +} diff --git a/src/GitHubExtension/DataModel/DataObjects/Repository.cs b/src/GitHubExtension/DataModel/DataObjects/Repository.cs index f0fc3d8f..2fbaf5be 100644 --- a/src/GitHubExtension/DataModel/DataObjects/Repository.cs +++ b/src/GitHubExtension/DataModel/DataObjects/Repository.cs @@ -109,6 +109,23 @@ public IEnumerable Issues } } + [Write(false)] + [Computed] + public IEnumerable Releases + { + get + { + if (DataStore == null) + { + return Enumerable.Empty(); + } + else + { + return Release.GetAllForRepository(DataStore, this) ?? Enumerable.Empty(); + } + } + } + public IEnumerable GetIssuesForQuery(string query) { if (DataStore == null) diff --git a/src/GitHubExtension/DataModel/GitHubDataStoreSchema.cs b/src/GitHubExtension/DataModel/GitHubDataStoreSchema.cs index ffeb9162..440a64ae 100644 --- a/src/GitHubExtension/DataModel/GitHubDataStoreSchema.cs +++ b/src/GitHubExtension/DataModel/GitHubDataStoreSchema.cs @@ -14,7 +14,7 @@ public GitHubDataStoreSchema() } // Update this anytime incompatible changes happen with a released version. - private const long SchemaVersionValue = 0x0006; + private const long SchemaVersionValue = 0x0007; private static readonly string Metadata = @"CREATE TABLE Metadata (" + @@ -248,6 +248,21 @@ public GitHubDataStoreSchema() ");" + "CREATE UNIQUE INDEX IDX_Review_InternalId ON Review (InternalId);"; + private static readonly string Release = + @"CREATE TABLE Release (" + + "Id INTEGER PRIMARY KEY NOT NULL," + + "InternalId INTEGER NOT NULL," + + "RepositoryId INTEGER NOT NULL," + + "Name TEXT NOT NULL COLLATE NOCASE," + + "TagName TEXT NOT NULL COLLATE NOCASE," + + "Prerelease INTEGER NOT NULL," + + "HtmlUrl TEXT NULL COLLATE NOCASE," + + "TimeCreated INTEGER NOT NULL," + + "TimePublished INTEGER NOT NULL," + + "TimeLastObserved INTEGER NOT NULL" + + ");" + + "CREATE UNIQUE INDEX IDX_Release_InternalId ON Release (InternalId);"; + // All Sqls together. private static readonly List SchemaSqlsValue = new() { @@ -269,5 +284,6 @@ public GitHubDataStoreSchema() Search, SearchIssue, Review, + Release, }; } diff --git a/src/GitHubExtension/GitHubExtension.csproj b/src/GitHubExtension/GitHubExtension.csproj index 15c68e57..30001ab7 100644 --- a/src/GitHubExtension/GitHubExtension.csproj +++ b/src/GitHubExtension/GitHubExtension.csproj @@ -26,6 +26,8 @@ + + @@ -38,9 +40,15 @@ Always + + Always + Always + + Always + Always @@ -137,6 +145,9 @@ Always + + Always + Always diff --git a/src/GitHubExtension/Helpers/Resources.cs b/src/GitHubExtension/Helpers/Resources.cs index e3051708..0b8ce7b6 100644 --- a/src/GitHubExtension/Helpers/Resources.cs +++ b/src/GitHubExtension/Helpers/Resources.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using DevHome.Logging; @@ -60,6 +60,7 @@ public static string[] GetWidgetResourceIdentifiers() "Widget_Template/EmptyAssigned", "Widget_Template/EmptyMentioned", "Widget_Template/EmptyReviews", + "Widget_Template/EmptyReleases", "Widget_Template/Pulls", "Widget_Template/Issues", "Widget_Template/Opened", @@ -102,6 +103,8 @@ public static string[] GetWidgetResourceIdentifiers() "Widget_Template_Tooltip/Save", "Widget_Template_Tooltip/Cancel", "Widget_Template/ChooseAccountPlaceholder", + "Widget_Template/Published", + "Widget_Template_Tooltip/OpenRelease", }; } } diff --git a/src/GitHubExtension/Strings/en-US/Resources.resw b/src/GitHubExtension/Strings/en-US/Resources.resw index f3efd99a..dde7ecd1 100644 --- a/src/GitHubExtension/Strings/en-US/Resources.resw +++ b/src/GitHubExtension/Strings/en-US/Resources.resw @@ -1,4 +1,4 @@ - + @@ -86,4 +145,12 @@ GitHub (Preview) The display name of our widgets provider + + List of releases in a GitHub repository. + Description for widget that displays the releases of a repository + + + Releases + Title for widget that displays the releases of a repository + \ No newline at end of file diff --git a/test/GitHubExtension/DataStore/DataObjectTests.cs b/test/GitHubExtension/DataStore/DataObjectTests.cs index 8af75741..82ce2ab0 100644 --- a/test/GitHubExtension/DataStore/DataObjectTests.cs +++ b/test/GitHubExtension/DataStore/DataObjectTests.cs @@ -594,4 +594,67 @@ public void ReadAndWriteReview() testListener.PrintEventCounts(); Assert.AreEqual(false, testListener.FoundErrors()); } + + [TestMethod] + [TestCategory("Unit")] + public void ReadAndWriteRelease() + { + using var log = new Logger("TestStore", TestOptions.LogOptions); + var testListener = new TestListener("TestListener", TestContext!); + log.AddListener(testListener); + Log.Attach(log); + + using var dataStore = new DataStore("TestStore", TestHelpers.GetDataStoreFilePath(TestOptions), TestOptions.DataStoreOptions.DataStoreSchema!); + Assert.IsNotNull(dataStore); + dataStore.Create(); + Assert.IsNotNull(dataStore.Connection); + + // Add repository record + dataStore.Connection.Insert(new Repository { OwnerId = 1, InternalId = 47, Name = "TestRepo1", Description = "Short Desc", HtmlUrl = "https://www.microsoft.com", DefaultBranch = "main" }); + + var releases = new List + { + { new Release { InternalId = 13, Name = "Release 0.0.1", TagName = "0.0.1", Prerelease = 1, HtmlUrl = "https://www.microsoft.com", RepositoryId = 1 } }, + { new Release { InternalId = 23, Name = "Release 1.0.0", TagName = "1.0.0", Prerelease = 0, HtmlUrl = "https://www.microsoft.com", RepositoryId = 1 } }, + }; + + using var tx = dataStore.Connection!.BeginTransaction(); + dataStore.Connection.Insert(releases[0]); + dataStore.Connection.Insert(releases[1]); + tx.Commit(); + + // Verify retrieval and input into data objects. + var dataStoreReleases = dataStore.Connection.GetAll().ToList(); + Assert.AreEqual(dataStoreReleases.Count, 2); + foreach (var release in dataStoreReleases) + { + // Get Repo info + var repo = dataStore.Connection.Get(release.RepositoryId); + + TestContext?.WriteLine($" Repo: {repo.Name} - {release.Name} - {release.TagName}"); + Assert.AreEqual("TestRepo1", repo.Name); + Assert.IsTrue(release.Id == 1 || release.Id == 2); + + if (release.Id == 1) + { + Assert.AreEqual(13, release.InternalId); + Assert.AreEqual("Release 0.0.1", release.Name); + Assert.AreEqual("0.0.1", release.TagName); + Assert.AreEqual(1, release.Prerelease); + Assert.AreEqual("https://www.microsoft.com", release.HtmlUrl); + } + + if (release.Id == 2) + { + Assert.AreEqual(23, release.InternalId); + Assert.AreEqual("Release 1.0.0", release.Name); + Assert.AreEqual("1.0.0", release.TagName); + Assert.AreEqual(0, release.Prerelease); + Assert.AreEqual("https://www.microsoft.com", release.HtmlUrl); + } + } + + testListener.PrintEventCounts(); + Assert.AreEqual(false, testListener.FoundErrors()); + } }