diff --git a/src/AzureExtension/DataManager/AzureDataManager.cs b/src/AzureExtension/DataManager/AzureDataManager.cs index 24c3a06..59dfb66 100644 --- a/src/AzureExtension/DataManager/AzureDataManager.cs +++ b/src/AzureExtension/DataManager/AzureDataManager.cs @@ -627,6 +627,12 @@ await ProcessPullRequests( return; } + public IEnumerable GetPullRequestsForLoggedInDeveloperIds() + { + ValidateDataStore(); + return PullRequests.GetAllForDeveloper(DataStore); + } + public async Task UpdatePullRequestsForLoggedInDeveloperIdsAsync(RequestOptions? options = null, Guid? requestor = null) { ValidateDataStore(); @@ -653,50 +659,35 @@ public async Task UpdatePullRequestsForLoggedInDeveloperIdsAsync(RequestOptions? return; } - SendPullRequestUpdateEvent(_log, this, parameters.Requestor, context); + SendDeveloperUpdateEvent(_log, this, parameters.Requestor, context); } private async Task UpdateDataForDeveloperPullRequestsAsync(DataStoreOperationParameters parameters) { _log.Debug($"Inside UpdateDataForDeveloperPullRequestsAsync with Parameters: {parameters}"); - dynamic context = new ExpandoObject(); - var contextDict = (IDictionary)context; - contextDict.Add("Requestor", parameters.Requestor); - - try + // This is a loop over a subset of repositories with a specific developer ID and pull request view specified. + var repositoryReferences = RepositoryReference.GetAll(DataStore); + foreach (var repositoryRef in repositoryReferences) { - // This is a loop over a subset of repositories with a specific developer ID and pull request view specified. - var repositoryReferences = RepositoryReference.GetAll(DataStore); - foreach (var repositoryRef in repositoryReferences) + var uri = new AzureUri(repositoryRef.Repository.CloneUrl); + var uris = new List { - var uri = new AzureUri(repositoryRef.Repository.CloneUrl); - var uris = new List - { - new(repositoryRef.Repository.CloneUrl), - }; + new(repositoryRef.Repository.CloneUrl), + }; - var suboperationParameters = new DataStoreOperationParameters - { - Uris = uris, - DeveloperId = repositoryRef.Developer.DeveloperId, - RequestOptions = parameters.RequestOptions, - OperationName = nameof(UpdateDataForDeveloperPullRequestsAsync), - PullRequestView = PullRequestView.Mine, - Requestor = parameters.Requestor, - }; - - await UpdateDataForPullRequestsAsync(suboperationParameters); - } - } - catch (Exception ex) - { - contextDict.Add("ErrorMessage", ex.Message); - SendErrorUpdateEvent(_log, this, parameters.Requestor, context, ex); - return; - } + var suboperationParameters = new DataStoreOperationParameters + { + Uris = uris, + DeveloperId = repositoryRef.Developer.DeveloperId, + RequestOptions = parameters.RequestOptions, + OperationName = nameof(UpdateDataForDeveloperPullRequestsAsync), + PullRequestView = PullRequestView.Mine, + Requestor = parameters.Requestor, + }; - SendDeveloperUpdateEvent(_log, this, parameters.Requestor, context); + await UpdateDataForPullRequestsAsync(suboperationParameters); + } } private async Task ProcessPullRequests( @@ -769,6 +760,7 @@ private async Task ProcessPullRequests( pullRequestObjFields.Add("Id", pullRequest.PullRequestId); pullRequestObjFields.Add("Title", pullRequest.Title); + pullRequestObjFields.Add("RepositoryId", repository.Id); pullRequestObjFields.Add("Status", pullRequest.Status); pullRequestObjFields.Add("PolicyStatus", status.ToString()); pullRequestObjFields.Add("PolicyStatusReason", statusReason); diff --git a/src/AzureExtension/DataManager/AzureDataManagerCache.cs b/src/AzureExtension/DataManager/AzureDataManagerCache.cs index 13ddaa8..7c5ee99 100644 --- a/src/AzureExtension/DataManager/AzureDataManagerCache.cs +++ b/src/AzureExtension/DataManager/AzureDataManagerCache.cs @@ -24,6 +24,8 @@ public partial class AzureDataManager private static readonly int _pullRequestRepositoryLimit = 25; + private static readonly TimeSpan _orgUpdateDelayTime = TimeSpan.FromSeconds(5); + public async Task UpdateDataForAccountsAsync(RequestOptions? options = null, Guid? requestor = null) { // A parameterless call will always update the data, effectively a 'force update'. @@ -104,11 +106,23 @@ public async Task UpdateDataForAccountsAsync(TimeSpan olderThan, RequestOptions? continue; } + var orgStartTime = DateTime.UtcNow; + UpdateOrganization(account, developerId, connection, cancellationToken); tx.Commit(); _log.Information($"Updated organization: {account.AccountName}"); + + // Send an update event once the transaction is completed so anyone waiting on the DB to be + // available has an opportunity to use it. + var orgUpdateContext = CreateUpdateEventContext(0, 1, 0, DateTime.UtcNow - orgStartTime); + SendAccountUpdateEvent(_log, this, requestorGuid, orgUpdateContext, firstException); ++accountsUpdated; + + // Delay to allow widgets and other code to respond to the event and use the database. + // This is to prevent DOS'ing widgets using the datastore during large cache updates of + // many organizations. + await Task.Delay(_orgUpdateDelayTime, cancellationToken); } catch (Exception ex) when (IsCancelException(ex)) { @@ -313,6 +327,11 @@ private static void SendCacheUpdateEvent(ILogger logger, object? source, Guid re SendUpdateEvent(logger, source, DataManagerUpdateKind.Cache, requestor, context, ex); } + private static void SendAccountUpdateEvent(ILogger logger, object? source, Guid requestor, dynamic context, Exception? ex) + { + SendUpdateEvent(logger, source, DataManagerUpdateKind.Account, requestor, context, ex); + } + private static bool IsCancelException(Exception ex) { return (ex is OperationCanceledException) || (ex is TaskCanceledException); diff --git a/src/AzureExtension/DataManager/CacheManager.cs b/src/AzureExtension/DataManager/CacheManager.cs index feae06d..4a9b47a 100644 --- a/src/AzureExtension/DataManager/CacheManager.cs +++ b/src/AzureExtension/DataManager/CacheManager.cs @@ -14,7 +14,7 @@ public class CacheManager : IDisposable private static readonly string _cacheManagerLastUpdatedMetaDataKey = "CacheManagerLastUpdated"; // Frequency the CacheManager checks for an update. - private static readonly TimeSpan _updateInterval = TimeSpan.FromHours(4); + private static readonly TimeSpan _updateInterval = TimeSpan.FromMinutes(15); private static readonly TimeSpan _defaultAccountUpdateFrequency = TimeSpan.FromDays(3); @@ -32,6 +32,8 @@ public class CacheManager : IDisposable public bool UpdateInProgress { get; private set; } + public bool NeverUpdated => LastUpdated == DateTime.MinValue; + public DateTime LastUpdated { get => GetLastUpdated(); @@ -193,6 +195,15 @@ private void HandleDataManagerUpdate(object? source, DataManagerUpdateEventArgs Log.Debug("DataManager update received"); switch (e.Kind) { + case DataManagerUpdateKind.Account: + // Account is sent after each organization has been updated, but the entire cache + // is not necessarily updated. We will treat this as an update is still in progress, and + // notify others who may be waiting for an opportunity to query the database. + // Receiving this event means a transaction was just completed and the datastore is + // briefly unlocked for queries. + SendUpdateEvent(this, CacheManagerUpdateKind.Account); + break; + case DataManagerUpdateKind.Cache: lock (_stateLock) { diff --git a/src/AzureExtension/DataManager/CacheManagerUpdateEventArgs.cs b/src/AzureExtension/DataManager/CacheManagerUpdateEventArgs.cs index e822099..e847b7e 100644 --- a/src/AzureExtension/DataManager/CacheManagerUpdateEventArgs.cs +++ b/src/AzureExtension/DataManager/CacheManagerUpdateEventArgs.cs @@ -12,6 +12,7 @@ public enum CacheManagerUpdateKind Cleared, Error, Cancel, + Account, } public class CacheManagerUpdateEventArgs : EventArgs diff --git a/src/AzureExtension/DataManager/DataManagerUpdateEventArgs.cs b/src/AzureExtension/DataManager/DataManagerUpdateEventArgs.cs index 2a3873e..8830eb9 100644 --- a/src/AzureExtension/DataManager/DataManagerUpdateEventArgs.cs +++ b/src/AzureExtension/DataManager/DataManagerUpdateEventArgs.cs @@ -11,6 +11,7 @@ public enum DataManagerUpdateKind PullRequest, Error, Cache, + Account, Cancel, Developer, } diff --git a/src/AzureExtension/DataManager/IAzureDataManager.cs b/src/AzureExtension/DataManager/IAzureDataManager.cs index f6f0ead..ddf8eb1 100644 --- a/src/AzureExtension/DataManager/IAzureDataManager.cs +++ b/src/AzureExtension/DataManager/IAzureDataManager.cs @@ -42,6 +42,8 @@ public interface IAzureDataManager : IDisposable IEnumerable GetDeveloperRepositories(); + IEnumerable GetPullRequestsForLoggedInDeveloperIds(); + // Repository name may not be unique across projects, and projects may not be unique across // organizations, so we need all three to identify the repository. PullRequests? GetPullRequests(string organization, string project, string repositoryName, string developerId, PullRequestView view); diff --git a/src/AzureExtension/DataModel/DataObjects/PullRequests.cs b/src/AzureExtension/DataModel/DataObjects/PullRequests.cs index 44cd44f..8e18fd2 100644 --- a/src/AzureExtension/DataModel/DataObjects/PullRequests.cs +++ b/src/AzureExtension/DataModel/DataObjects/PullRequests.cs @@ -154,6 +154,23 @@ public static PullRequests Get(DataStore dataStore, long id) return Get(dataStore, project.Id, repository.Id, developerLogin, view); } + public static IEnumerable GetAllForDeveloper(DataStore dataStore) + { + var sql = @"SELECT * FROM PullRequests WHERE ViewId = @ViewId;"; + var param = new + { + ViewId = (long)PullRequestView.Mine, + }; + + var pullRequestsSet = dataStore.Connection!.Query(sql, param, null) ?? []; + foreach (var pullRequestsEntry in pullRequestsSet) + { + pullRequestsEntry.DataStore = dataStore; + } + + return pullRequestsSet; + } + public static PullRequests GetOrCreate(DataStore dataStore, long repositoryId, long projectId, string developerId, PullRequestView view, string pullRequests) { var newDeveloperPullRequests = Create(repositoryId, projectId, developerId, view, pullRequests); diff --git a/src/AzureExtension/Strings/en-US/Resources.resw b/src/AzureExtension/Strings/en-US/Resources.resw index c221891..3798dd4 100644 --- a/src/AzureExtension/Strings/en-US/Resources.resw +++ b/src/AzureExtension/Strings/en-US/Resources.resw @@ -726,4 +726,8 @@ Rejected Shown in Toast Notification, title line + + Finding your pull requests. This may take several minutes. + Shown in Widget + \ No newline at end of file diff --git a/src/AzureExtension/Widgets/AzurePullRequestsBaseWidget.cs b/src/AzureExtension/Widgets/AzurePullRequestsBaseWidget.cs new file mode 100644 index 0000000..4b113cc --- /dev/null +++ b/src/AzureExtension/Widgets/AzurePullRequestsBaseWidget.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using DevHomeAzureExtension.Client; +using DevHomeAzureExtension.DataManager; +using DevHomeAzureExtension.DataModel; +using DevHomeAzureExtension.DeveloperId; +using DevHomeAzureExtension.Helpers; +using Microsoft.Windows.Widgets.Providers; +using Newtonsoft.Json; + +namespace DevHomeAzureExtension.Widgets; + +internal abstract class AzurePullRequestsBaseWidget : AzureWidget +{ + private readonly string _sampleIconData = IconLoader.GetIconAsBase64("screenshot.png"); + + // Widget Data + protected string WidgetTitle { get; set; } = string.Empty; + + protected string? Message { get; set; } + + // Creation and destruction methods + public AzurePullRequestsBaseWidget() + : base() + { + } + + public override void CreateWidget(WidgetContext widgetContext, string state) + { + if (state.Length != 0) + { + ResetDataFromState(state); + } + + base.CreateWidget(widgetContext, state); + } + + public override void DeleteWidget(string widgetId, string customState) + { + base.DeleteWidget(widgetId, customState); + } + + protected string GetIconForPullRequestStatus(string? prStatus) + { + prStatus ??= string.Empty; + if (Enum.TryParse(prStatus, false, out var policyStatus)) + { + return policyStatus switch + { + PolicyStatus.Approved => IconLoader.GetIconAsBase64("PullRequestApproved.png"), + PolicyStatus.Running => IconLoader.GetIconAsBase64("PullRequestWaiting.png"), + PolicyStatus.Queued => IconLoader.GetIconAsBase64("PullRequestWaiting.png"), + PolicyStatus.Rejected => IconLoader.GetIconAsBase64("PullRequestRejected.png"), + PolicyStatus.Broken => IconLoader.GetIconAsBase64("PullRequestRejected.png"), + _ => IconLoader.GetIconAsBase64("PullRequestReviewNotStarted.png"), + }; + } + + return string.Empty; + } + + protected override bool ValidateConfiguration(WidgetActionInvokedArgs args) + { + return true; + } + + // Increase precision of SetDefaultDeveloperLoginId by matching the selectedRepositoryUrl's org + // with the first matching DeveloperId that contains that org. + protected override void SetDefaultDeveloperLoginId() + { + base.SetDefaultDeveloperLoginId(); + } + + // Data loading methods + public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEventArgs e) + { + return; + } + + public override void RequestContentData() + { + return; + } + + protected override void ResetDataFromState(string data) + { + return; + } + + public override string GetConfiguration(string data) + { + return EmptyJson; + } + + public override void LoadContentData() + { + return; + } + + // Overriding methods from Widget base + public override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.SignIn => @"Widgets\Templates\AzureSignInTemplate.json", + WidgetPageState.Configure => @"Widgets\Templates\AzurePullRequestsConfigurationTemplate.json", + WidgetPageState.Content => @"Widgets\Templates\AzurePullRequestsTemplate.json", + WidgetPageState.Loading => @"Widgets\Templates\AzureLoadingTemplate.json", + _ => throw new NotImplementedException(Page.GetType().Name), + }; + } + + public override string GetData(WidgetPageState page) + { + return page switch + { + WidgetPageState.SignIn => GetSignIn(), + WidgetPageState.Configure => GetConfiguration(string.Empty), + WidgetPageState.Content => ContentData, + WidgetPageState.Loading => GetLoadingMessage(), + _ => throw new NotImplementedException(Page.GetType().Name), + }; + } +} diff --git a/src/AzureExtension/Widgets/AzurePullRequestsDeveloperWidget.cs b/src/AzureExtension/Widgets/AzurePullRequestsDeveloperWidget.cs new file mode 100644 index 0000000..f7910ce --- /dev/null +++ b/src/AzureExtension/Widgets/AzurePullRequestsDeveloperWidget.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using DevHomeAzureExtension.Client; +using DevHomeAzureExtension.DataManager; +using DevHomeAzureExtension.DataModel; +using DevHomeAzureExtension.DeveloperId; +using DevHomeAzureExtension.Helpers; +using Microsoft.Windows.Widgets.Providers; +using Newtonsoft.Json; + +namespace DevHomeAzureExtension.Widgets; + +internal sealed class AzurePullRequestsDeveloperWidget : AzurePullRequestsBaseWidget +{ + private readonly CacheManager _cacheManager; + + public AzurePullRequestsDeveloperWidget() + : base() + { + SupportsCustomization = false; + DeveloperIdLoginRequired = false; + _cacheManager = CacheManager.GetInstance(); + LoadingMessage = Resources.GetResource("Widget_Template/LoadingFindingPullRequests", Log); + } + + // Data loading methods + public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEventArgs e) + { + if (e.Kind == DataManagerUpdateKind.Developer) + { + Log.Debug($"Received developer pull request update."); + + if (e.Kind == DataManagerUpdateKind.Error) + { + DataState = WidgetDataState.FailedUpdate; + DataErrorMessage = e.Context.ErrorMessage; + + // The DataManager log will have detailed exception info, use the short message. + Log.Error($"Developer pull request update failed. {e.Context.ErrorMessage}"); + + if (!LoadedDataSuccessfully) + { + SetLoading(); + } + + return; + } + + DataState = WidgetDataState.Okay; + DataErrorMessage = string.Empty; + LoadContentData(); + } + + // If we failed data state, any data update might be an opportunity to retry since any + // update means a transaction has completed. + if (DataState == WidgetDataState.FailedRead) + { + Log.Debug("Retrying datastore read."); + LoadContentData(); + return; + } + } + + protected override bool ValidateConfiguration(WidgetActionInvokedArgs args) + { + return true; + } + + public override void RequestContentData() + { + // This widget does not request data, so we will use the periodic update + // as a refresh to try to recover from any transient issue where + // data was unavailable for whatever reason. + LoadContentData(); + } + + protected override void ResetDataFromState(string data) + { + // This widget has no state. + return; + } + + public override string GetConfiguration(string data) + { + // This widget has no configuration. + return string.Empty; + } + + public override void LoadContentData() + { + if (_cacheManager.NeverUpdated) + { + SetLoading(); + return; + } + + try + { + // This can throw if DataStore is not connected. + var pullRequestsList = DataManager!.GetPullRequestsForLoggedInDeveloperIds(); + var itemsData = new JsonObject(); + var itemsArray = new JsonArray(); + + foreach (var pullRequests in pullRequestsList.Where(x => x is not null)) + { + var pullRequestsResults = JsonConvert.DeserializeObject>(pullRequests.Results); + if (pullRequestsResults is null) + { + continue; + } + + foreach (var element in pullRequestsResults) + { + var workItem = JsonObject.Parse(element.Value.ToStringInvariant()); + + if (workItem != null) + { + // If we can't get the real date, it is better better to show a recent + // closer-to-correct time than the zero value decades ago, so use DateTime.UtcNow. + var dateTicks = workItem["CreationDate"]?.GetValue() ?? DateTime.UtcNow.Ticks; + var dateTime = dateTicks.ToDateTime(); + var creator = DataManager.GetIdentity(workItem["CreatedBy"]?.GetValue() ?? 0L); + var item = new JsonObject + { + { "title", workItem["Title"]?.GetValue() ?? string.Empty }, + { "url", workItem["HtmlUrl"]?.GetValue() ?? string.Empty }, + { "status_icon", GetIconForPullRequestStatus(workItem["PolicyStatus"]?.GetValue()) }, + { "number", element.Key }, + { "date", TimeSpanHelper.DateTimeOffsetToDisplayString(dateTime, Log) }, + { "dateTicks", dateTicks }, + { "user", creator.Name }, + { "branch", workItem["TargetBranch"]?.GetValue().Replace("refs/heads/", string.Empty) }, + { "avatar", creator.Avatar }, + }; + + itemsArray.Add(item); + } + } + } + + // Sort all pull requests by creation date, descending so newer are at the top of the list. + var sortedItems = itemsArray.Where(x => x is not null).OrderByDescending(x => x?["dateTicks"]?.GetValue()); + var sortedItemsArray = new JsonArray(); + foreach (var item in sortedItems) + { + // Parent is read-only and fixed, use deep clone to re-parent the node. + var itemClone = item?.DeepClone(); + sortedItemsArray.Add(itemClone); + } + + itemsData.Add("maxItemsDisplayed", AzureDataManager.PullRequestResultLimit); + itemsData.Add("items", sortedItemsArray); + itemsData.Add("widgetTitle", WidgetTitle); + itemsData.Add("is_loading_data", !LoadedDataSuccessfully); + + ContentData = itemsData.ToJsonString(); + DataState = WidgetDataState.Okay; + UpdateActivityState(); + } + catch (Exception e) + { + Log.Error(e, "Error retrieving data."); + DataState = WidgetDataState.FailedRead; + + if (!LoadedDataSuccessfully) + { + SetLoading(); + } + + return; + } + } + + public override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.SignIn => @"Widgets\Templates\AzureSignInTemplate.json", + WidgetPageState.Content => @"Widgets\Templates\AzurePullRequestsTemplate.json", + WidgetPageState.Loading => @"Widgets\Templates\AzureLoadingTemplate.json", + _ => throw new NotImplementedException(Page.GetType().Name), + }; + } + + public override string GetData(WidgetPageState page) + { + return page switch + { + WidgetPageState.SignIn => GetSignIn(), + WidgetPageState.Content => ContentData, + WidgetPageState.Loading => GetLoadingMessage(), + _ => throw new NotImplementedException(Page.GetType().Name), + }; + } +} diff --git a/src/AzureExtension/Widgets/AzurePullRequestsWidget.cs b/src/AzureExtension/Widgets/AzurePullRequestsRepositoryWidget.cs similarity index 67% rename from src/AzureExtension/Widgets/AzurePullRequestsWidget.cs rename to src/AzureExtension/Widgets/AzurePullRequestsRepositoryWidget.cs index 6c39002..9dff8f9 100644 --- a/src/AzureExtension/Widgets/AzurePullRequestsWidget.cs +++ b/src/AzureExtension/Widgets/AzurePullRequestsRepositoryWidget.cs @@ -1,357 +1,332 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; -using DevHomeAzureExtension.Client; -using DevHomeAzureExtension.DataManager; -using DevHomeAzureExtension.DataModel; -using DevHomeAzureExtension.DeveloperId; -using DevHomeAzureExtension.Helpers; -using Microsoft.Windows.Widgets.Providers; -using Newtonsoft.Json; - -namespace DevHomeAzureExtension.Widgets; - -internal sealed class AzurePullRequestsWidget : AzureWidget -{ - private readonly string _sampleIconData = IconLoader.GetIconAsBase64("screenshot.png"); - - private const string DefaultSelectedView = "Mine"; - - // Widget Data - private string _widgetTitle = string.Empty; - private string _selectedRepositoryUrl = string.Empty; - private string _selectedRepositoryName = string.Empty; - private string _selectedView = DefaultSelectedView; - private string? _message; - - // Creation and destruction methods - public AzurePullRequestsWidget() - : base() - { - } - - public override void CreateWidget(WidgetContext widgetContext, string state) - { - if (state.Length != 0) - { - ResetDataFromState(state); - } - - base.CreateWidget(widgetContext, state); - } - - public override void DeleteWidget(string widgetId, string customState) - { - base.DeleteWidget(widgetId, customState); - } - - // Helper methods - private PullRequestView GetPullRequestView(string viewStr) - { - try - { - return Enum.Parse(viewStr); - } - catch (Exception) - { - Log.Error($"Unknown Pull Request view for string: {viewStr}"); - return PullRequestView.Unknown; - } - } - - private string GetIconForPullRequestStatus(string? prStatus) - { - prStatus ??= string.Empty; - if (Enum.TryParse(prStatus, false, out var policyStatus)) - { - return policyStatus switch - { - PolicyStatus.Approved => IconLoader.GetIconAsBase64("PullRequestApproved.png"), - PolicyStatus.Running => IconLoader.GetIconAsBase64("PullRequestWaiting.png"), - PolicyStatus.Queued => IconLoader.GetIconAsBase64("PullRequestWaiting.png"), - PolicyStatus.Rejected => IconLoader.GetIconAsBase64("PullRequestRejected.png"), - PolicyStatus.Broken => IconLoader.GetIconAsBase64("PullRequestRejected.png"), - _ => IconLoader.GetIconAsBase64("PullRequestReviewNotStarted.png"), - }; - } - - return string.Empty; - } - - protected override bool ValidateConfiguration(WidgetActionInvokedArgs args) - { - // Set loading page while we fetch data from ADO. - Page = WidgetPageState.Loading; - UpdateWidget(); - - CanSave = false; - - Page = WidgetPageState.Configure; - var data = args.Data; - var dataObject = JsonObject.Parse(data); - _message = null; - if (dataObject != null && dataObject["account"] != null && dataObject["query"] != null) - { - _widgetTitle = dataObject["widgetTitle"]?.GetValue() ?? string.Empty; - DeveloperLoginId = dataObject["account"]?.GetValue() ?? string.Empty; - _selectedRepositoryUrl = dataObject["query"]?.GetValue() ?? string.Empty; - _selectedView = dataObject["view"]?.GetValue() ?? string.Empty; - SetDefaultDeveloperLoginId(); - if (DeveloperLoginId != dataObject["account"]?.GetValue()) - { - dataObject["account"] = DeveloperLoginId; - data = dataObject.ToJsonString(); - } - - ConfigurationData = data; - - var developerId = GetDevId(DeveloperLoginId); - if (developerId == null) - { - _message = Resources.GetResource(@"Widget_Template/DevIDError"); - UpdateActivityState(); - return false; - } - - var repositoryInfo = AzureClientHelpers.GetRepositoryInfo(_selectedRepositoryUrl, developerId); - if (repositoryInfo.Result != ResultType.Success) - { - _message = GetMessageForError(repositoryInfo.Error, repositoryInfo.ErrorMessage); - UpdateActivityState(); - return false; - } - - CanSave = true; - Pinned = true; - Page = WidgetPageState.Content; - UpdateActivityState(); - return true; - } - - return false; - } - - public override void OnCustomizationRequested(WidgetCustomizationRequestedArgs customizationRequestedArgs) - { - // Set CanSave to false so user will have to Submit again before Saving. - CanSave = false; - SavedConfigurationData = ConfigurationData; - SetConfigure(); - } - - // Increase precision of SetDefaultDeveloperLoginId by matching the selectedRepositoryUrl's org - // with the first matching DeveloperId that contains that org. - protected override void SetDefaultDeveloperLoginId() - { - base.SetDefaultDeveloperLoginId(); - var azureOrg = new AzureUri(_selectedRepositoryUrl).Organization; - if (!string.IsNullOrEmpty(azureOrg)) - { - var devIds = DeveloperIdProvider.GetInstance().GetLoggedInDeveloperIds().DeveloperIds; - if (devIds is null) - { - return; - } - - DeveloperLoginId = devIds.Where(i => i.LoginId.Contains(azureOrg, StringComparison.OrdinalIgnoreCase)).FirstOrDefault()?.LoginId ?? DeveloperLoginId; - } - } - - // Data loading methods - public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEventArgs e) - { - if (e.Requestor.ToString() == Id) - { - Log.Debug($"Received matching query update"); - - if (e.Kind == DataManagerUpdateKind.Error) - { - DataState = WidgetDataState.Failed; - DataErrorMessage = e.Context.ErrorMessage; - - // The DataManager log will have detailed exception info, use the short message. - Log.Error($"Data update failed. {e.Context.QueryId} {e.Context.ErrorMessage}"); - return; - } - - DataState = WidgetDataState.Okay; - DataErrorMessage = string.Empty; - LoadContentData(); - } - } - - public override void RequestContentData() - { - var developerId = GetDevId(DeveloperLoginId); - if (developerId == null) - { - // Should not happen - Log.Error("Failed to get DeveloperId"); - return; - } - - try - { - var requestOptions = RequestOptions.RequestOptionsDefault(); - var azureUri = new AzureUri(_selectedRepositoryUrl); - DataManager!.UpdateDataForPullRequestsAsync(azureUri, developerId.LoginId, GetPullRequestView(_selectedView), requestOptions, new Guid(Id)); - } - catch (Exception ex) - { - Log.Error(ex, "Failed requesting data update."); - } - } - - protected override void ResetDataFromState(string data) - { - var dataObject = JsonObject.Parse(data); - - if (dataObject == null) - { - return; - } - - _widgetTitle = dataObject["widgetTitle"]?.GetValue() ?? string.Empty; - DeveloperLoginId = dataObject["account"]?.GetValue() ?? string.Empty; - _selectedRepositoryUrl = dataObject["query"]?.GetValue() ?? string.Empty; - _selectedView = dataObject["view"]?.GetValue() ?? string.Empty; - _message = null; - - var developerId = GetDevId(DeveloperLoginId); - if (developerId == null) - { - return; - } - - var azureUri = new AzureUri(_selectedRepositoryUrl); - _selectedRepositoryName = azureUri.Repository; - } - - public override string GetConfiguration(string data) - { - var configurationData = new JsonObject(); - - var developerIdsData = new JsonArray(); - - var devIdProvider = DeveloperIdProvider.GetInstance(); - - foreach (var developerId in devIdProvider.GetLoggedInDeveloperIds().DeveloperIds) - { - developerIdsData.Add(new JsonObject - { - { "devId", developerId.LoginId }, - }); - } - - configurationData.Add("accounts", developerIdsData); - - configurationData.Add("selectedDevId", DeveloperLoginId); - configurationData.Add("url", _selectedRepositoryUrl); - configurationData.Add("selectedView", _selectedView); - configurationData.Add("message", _message); - configurationData.Add("widgetTitle", _widgetTitle); - - configurationData.Add("pinned", Pinned); - configurationData.Add("arrow", IconLoader.GetIconAsBase64("arrow.png")); - - return configurationData.ToString(); - } - - public override void LoadContentData() - { - try - { - var developerId = GetDevId(DeveloperLoginId); - if (developerId == null) - { - // Should not happen - Log.Error("Failed to get Dev ID"); - return; - } - - var azureUri = new AzureUri(_selectedRepositoryUrl); - if (!azureUri.IsRepository) - { - Log.Error($"Invalid Uri: {_selectedRepositoryUrl}"); - return; - } - - PullRequests? pullRequests; - - // This can throw if DataStore is not connected. - pullRequests = DataManager!.GetPullRequests(azureUri, developerId.LoginId, GetPullRequestView(_selectedView)); - - var pullRequestsResults = pullRequests is null ? new Dictionary() : JsonConvert.DeserializeObject>(pullRequests.Results); - - var itemsData = new JsonObject(); - var itemsArray = new JsonArray(); - - foreach (var element in pullRequestsResults!) - { - var workItem = JsonObject.Parse(element.Value.ToStringInvariant()); - - if (workItem != null) - { - // If we can't get the real date, it is better better to show a recent - // closer-to-correct time than the zero value decades ago, so use DateTime.UtcNow. - var dateTicks = workItem["CreationDate"]?.GetValue() ?? DateTime.UtcNow.Ticks; - var dateTime = dateTicks.ToDateTime(); - var creator = DataManager.GetIdentity(workItem["CreatedBy"]?.GetValue() ?? 0L); - var item = new JsonObject - { - { "title", workItem["Title"]?.GetValue() ?? string.Empty }, - { "url", workItem["HtmlUrl"]?.GetValue() ?? string.Empty }, - { "status_icon", GetIconForPullRequestStatus(workItem["PolicyStatus"]?.GetValue()) }, - { "number", element.Key }, - { "date", TimeSpanHelper.DateTimeOffsetToDisplayString(dateTime, Log) }, - { "user", creator.Name }, - { "branch", workItem["TargetBranch"]?.GetValue().Replace("refs/heads/", string.Empty) }, - { "avatar", creator.Avatar }, - }; - - itemsArray.Add(item); - } - } - - itemsData.Add("maxItemsDisplayed", AzureDataManager.PullRequestResultLimit); - itemsData.Add("items", itemsArray); - itemsData.Add("widgetTitle", _widgetTitle); - itemsData.Add("is_loading_data", DataState == WidgetDataState.Unknown); - - ContentData = itemsData.ToJsonString(); - UpdateActivityState(); - } - catch (Exception e) - { - Log.Error(e, "Error retrieving data."); - DataState = WidgetDataState.Failed; - return; - } - } - - // Overriding methods from Widget base - public override string GetTemplatePath(WidgetPageState page) - { - return page switch - { - WidgetPageState.SignIn => @"Widgets\Templates\AzureSignInTemplate.json", - WidgetPageState.Configure => @"Widgets\Templates\AzurePullRequestsConfigurationTemplate.json", - WidgetPageState.Content => @"Widgets\Templates\AzurePullRequestsTemplate.json", - WidgetPageState.Loading => @"Widgets\Templates\AzureLoadingTemplate.json", - _ => throw new NotImplementedException(), - }; - } - - public override string GetData(WidgetPageState page) - { - return page switch - { - WidgetPageState.SignIn => GetSignIn(), - WidgetPageState.Configure => GetConfiguration(string.Empty), - WidgetPageState.Content => ContentData, - WidgetPageState.Loading => EmptyJson, - _ => throw new NotImplementedException(Page.GetType().Name), - }; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using DevHomeAzureExtension.Client; +using DevHomeAzureExtension.DataManager; +using DevHomeAzureExtension.DataModel; +using DevHomeAzureExtension.DeveloperId; +using DevHomeAzureExtension.Helpers; +using Microsoft.Windows.Widgets.Providers; +using Newtonsoft.Json; + +namespace DevHomeAzureExtension.Widgets; + +internal sealed class AzurePullRequestsRepositoryWidget : AzurePullRequestsBaseWidget +{ + private static readonly string _defaultSelectedView = PullRequestView.Mine.ToString(); + + // Widget Data + private string _selectedRepositoryUrl = string.Empty; + private string _selectedView = _defaultSelectedView; + + // Creation and destruction methods + public AzurePullRequestsRepositoryWidget() + : base() + { + } + + // Helper methods + private PullRequestView GetPullRequestView(string viewStr) + { + try + { + return Enum.Parse(viewStr); + } + catch (Exception) + { + Log.Error($"Unknown Pull Request view for string: {viewStr}"); + return PullRequestView.Unknown; + } + } + + // Data loading methods + public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEventArgs e) + { + if (e.Requestor.ToString() == Id) + { + Log.Debug($"Received matching DataStore update"); + + if (e.Kind == DataManagerUpdateKind.Error) + { + DataState = WidgetDataState.FailedUpdate; + DataErrorMessage = e.Context.ErrorMessage; + + // The DataManager log will have detailed exception info, use the short message. + Log.Error($"Data update failed. {e.Context.ErrorMessage}"); + + if (!LoadedDataSuccessfully) + { + // We have not loaded data successfully, so show a loading page + // instead of an error page. + SetLoading(); + } + + return; + } + + DataState = WidgetDataState.Okay; + DataErrorMessage = string.Empty; + LoadContentData(); + return; + } + + // If we failed data state, any data update might be an opportunity to retry since any + // update means a transaction has completed. + if (DataState == WidgetDataState.FailedRead) + { + Log.Debug("Retrying datastore read."); + LoadContentData(); + return; + } + + // If we failed to update, try again after a data update has been received. + if (DataState == WidgetDataState.FailedUpdate) + { + Log.Debug("Retrying datastore update."); + RequestContentData(); + return; + } + } + + protected override bool ValidateConfiguration(WidgetActionInvokedArgs args) + { + // Set loading page while we fetch data from ADO. + Page = WidgetPageState.Loading; + UpdateWidget(); + + CanSave = false; + + Page = WidgetPageState.Configure; + var data = args.Data; + var dataObject = JsonObject.Parse(data); + Message = null; + if (dataObject != null && dataObject["account"] != null && dataObject["query"] != null) + { + WidgetTitle = dataObject["widgetTitle"]?.GetValue() ?? string.Empty; + DeveloperLoginId = dataObject["account"]?.GetValue() ?? string.Empty; + _selectedRepositoryUrl = dataObject["query"]?.GetValue() ?? string.Empty; + _selectedView = dataObject["view"]?.GetValue() ?? string.Empty; + SetDefaultDeveloperLoginId(); + if (DeveloperLoginId != dataObject["account"]?.GetValue()) + { + dataObject["account"] = DeveloperLoginId; + data = dataObject.ToJsonString(); + } + + ConfigurationData = data; + + var developerId = GetDevId(DeveloperLoginId); + if (developerId == null) + { + Message = Resources.GetResource(@"Widget_Template/DevIDError"); + UpdateActivityState(); + return false; + } + + var repositoryInfo = AzureClientHelpers.GetRepositoryInfo(_selectedRepositoryUrl, developerId); + if (repositoryInfo.Result != ResultType.Success) + { + Message = GetMessageForError(repositoryInfo.Error, repositoryInfo.ErrorMessage); + UpdateActivityState(); + return false; + } + + CanSave = true; + Pinned = true; + Page = WidgetPageState.Content; + UpdateActivityState(); + return true; + } + + return false; + } + + // Increase precision of SetDefaultDeveloperLoginId by matching the selectedRepositoryUrl's org + // with the first matching DeveloperId that contains that org. + protected override void SetDefaultDeveloperLoginId() + { + var azureOrg = new AzureUri(_selectedRepositoryUrl).Organization; + if (!string.IsNullOrEmpty(azureOrg)) + { + var devIds = DeveloperIdProvider.GetInstance().GetLoggedInDeveloperIds().DeveloperIds; + if (devIds is null) + { + return; + } + + DeveloperLoginId = devIds.Where(i => i.LoginId.Contains(azureOrg, StringComparison.OrdinalIgnoreCase)).FirstOrDefault()?.LoginId ?? DeveloperLoginId; + } + } + + public override void RequestContentData() + { + if (DataState == WidgetDataState.Requested) + { + return; + } + + var developerId = GetDevId(DeveloperLoginId); + if (developerId == null) + { + // Should not happen + Log.Error("Failed to get DeveloperId"); + DataState = WidgetDataState.FailedUpdate; + return; + } + + try + { + var requestOptions = RequestOptions.RequestOptionsDefault(); + var azureUri = new AzureUri(_selectedRepositoryUrl); + DataState = WidgetDataState.Requested; + DataManager!.UpdateDataForPullRequestsAsync(azureUri, developerId.LoginId, GetPullRequestView(_selectedView), requestOptions, new Guid(Id)); + } + catch (Exception ex) + { + Log.Error(ex, "Failed requesting data update."); + DataState = WidgetDataState.FailedUpdate; + } + } + + protected override void ResetDataFromState(string data) + { + var dataObject = JsonObject.Parse(data); + + if (dataObject == null) + { + return; + } + + WidgetTitle = dataObject["widgetTitle"]?.GetValue() ?? string.Empty; + DeveloperLoginId = dataObject["account"]?.GetValue() ?? string.Empty; + _selectedRepositoryUrl = dataObject["query"]?.GetValue() ?? string.Empty; + _selectedView = dataObject["view"]?.GetValue() ?? string.Empty; + Message = null; + + var developerId = GetDevId(DeveloperLoginId); + if (developerId == null) + { + return; + } + } + + public override string GetConfiguration(string data) + { + var configurationData = new JsonObject(); + + var developerIdsData = new JsonArray(); + + var devIdProvider = DeveloperIdProvider.GetInstance(); + + foreach (var developerId in devIdProvider.GetLoggedInDeveloperIds().DeveloperIds) + { + developerIdsData.Add(new JsonObject + { + { "devId", developerId.LoginId }, + }); + } + + configurationData.Add("accounts", developerIdsData); + + configurationData.Add("selectedDevId", DeveloperLoginId); + configurationData.Add("url", _selectedRepositoryUrl); + configurationData.Add("selectedView", _selectedView); + configurationData.Add("message", Message); + configurationData.Add("widgetTitle", WidgetTitle); + + configurationData.Add("pinned", Pinned); + configurationData.Add("arrow", IconLoader.GetIconAsBase64("arrow.png")); + + return configurationData.ToString(); + } + + public override void LoadContentData() + { + if (!LoadedDataSuccessfully) + { + SetLoading(); + } + + try + { + var developerId = GetDevId(DeveloperLoginId); + if (developerId == null) + { + // Should not happen + Log.Error("Failed to get Dev ID"); + return; + } + + var azureUri = new AzureUri(_selectedRepositoryUrl); + if (!azureUri.IsRepository) + { + Log.Error($"Invalid Uri: {_selectedRepositoryUrl}"); + return; + } + + PullRequests? pullRequests; + + // This can throw if DataStore is not connected. + pullRequests = DataManager!.GetPullRequests(azureUri, developerId.LoginId, GetPullRequestView(_selectedView)); + + var pullRequestsResults = pullRequests is null ? new Dictionary() : JsonConvert.DeserializeObject>(pullRequests.Results); + + var itemsData = new JsonObject(); + var itemsArray = new JsonArray(); + + foreach (var element in pullRequestsResults!) + { + var workItem = JsonObject.Parse(element.Value.ToStringInvariant()); + + if (workItem != null) + { + // If we can't get the real date, it is better better to show a recent + // closer-to-correct time than the zero value decades ago, so use DateTime.UtcNow. + var dateTicks = workItem["CreationDate"]?.GetValue() ?? DateTime.UtcNow.Ticks; + var dateTime = dateTicks.ToDateTime(); + var creator = DataManager.GetIdentity(workItem["CreatedBy"]?.GetValue() ?? 0L); + var item = new JsonObject + { + { "title", workItem["Title"]?.GetValue() ?? string.Empty }, + { "url", workItem["HtmlUrl"]?.GetValue() ?? string.Empty }, + { "status_icon", GetIconForPullRequestStatus(workItem["PolicyStatus"]?.GetValue()) }, + { "number", element.Key }, + { "date", TimeSpanHelper.DateTimeOffsetToDisplayString(dateTime, Log) }, + { "user", creator.Name }, + { "branch", workItem["TargetBranch"]?.GetValue().Replace("refs/heads/", string.Empty) }, + { "avatar", creator.Avatar }, + }; + + itemsArray.Add(item); + } + } + + if (string.IsNullOrEmpty(WidgetTitle)) + { + WidgetTitle = azureUri.Repository; + } + + itemsData.Add("maxItemsDisplayed", AzureDataManager.PullRequestResultLimit); + itemsData.Add("items", itemsArray); + itemsData.Add("widgetTitle", WidgetTitle); + itemsData.Add("is_loading_data", DataState != WidgetDataState.Unknown); + + ContentData = itemsData.ToJsonString(); + DataState = WidgetDataState.Okay; + LoadedDataSuccessfully = true; + UpdateActivityState(); + } + catch (Exception e) + { + Log.Error(e, "Error retrieving data."); + DataState = WidgetDataState.FailedRead; + if (!LoadedDataSuccessfully) + { + SetLoading(); + } + + return; + } + } +} diff --git a/src/AzureExtension/Widgets/AzureQueryListWidget.cs b/src/AzureExtension/Widgets/AzureQueryListWidget.cs index 35d4e3d..bf10f4c 100644 --- a/src/AzureExtension/Widgets/AzureQueryListWidget.cs +++ b/src/AzureExtension/Widgets/AzureQueryListWidget.cs @@ -165,7 +165,7 @@ public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEv if (e.Kind == DataManagerUpdateKind.Error) { - DataState = WidgetDataState.Failed; + DataState = WidgetDataState.FailedUpdate; DataErrorMessage = e.Context.ErrorMessage; // The DataManager log will have detailed exception info, use the short message. @@ -177,6 +177,15 @@ public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEv DataErrorMessage = string.Empty; LoadContentData(); } + + // If we failed data state, any data update might be an opportunity to retry since any + // update means a transaction has completed. + if (DataState == WidgetDataState.FailedRead) + { + Log.Debug("Retrying datastore read."); + LoadContentData(); + return; + } } public override void RequestContentData() @@ -331,7 +340,7 @@ public override void LoadContentData() catch (Exception e) { Log.Error(e, "Error retrieving data."); - DataState = WidgetDataState.Failed; + DataState = WidgetDataState.FailedRead; return; } } diff --git a/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs b/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs index cf677f4..bf80d79 100644 --- a/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs +++ b/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs @@ -183,7 +183,7 @@ public override void HandleDataManagerUpdate(object? source, DataManagerUpdateEv if (e.Kind == DataManagerUpdateKind.Error) { - DataState = WidgetDataState.Failed; + DataState = WidgetDataState.FailedUpdate; DataErrorMessage = e.Context.ErrorMessage; // The DataManager log will have detailed exception info, use the short message. diff --git a/src/AzureExtension/Widgets/AzureWidget.cs b/src/AzureExtension/Widgets/AzureWidget.cs index b34c5f2..0c5fd94 100644 --- a/src/AzureExtension/Widgets/AzureWidget.cs +++ b/src/AzureExtension/Widgets/AzureWidget.cs @@ -34,8 +34,14 @@ public abstract class AzureWidget : WidgetImpl protected string ContentData { get; set; } = EmptyJson; + protected string LoadingMessage { get; set; } = string.Empty; + protected string DeveloperLoginId { get; set; } = string.Empty; + protected bool DeveloperIdLoginRequired { get; set; } = true; + + protected bool LoadedDataSuccessfully { get; set; } + protected bool CanSave { get; set; @@ -156,6 +162,7 @@ public override void OnActionInvoked(WidgetActionInvokedArgs actionInvokedArgs) SavedConfigurationData = string.Empty; ContentData = EmptyJson; DataState = WidgetDataState.Unknown; + LoadedDataSuccessfully = false; SetActive(); } @@ -181,6 +188,8 @@ public override void OnActionInvoked(WidgetActionInvokedArgs actionInvokedArgs) public override void OnCustomizationRequested(WidgetCustomizationRequestedArgs customizationRequestedArgs) { + // Set CanSave to false so user will have to Submit again before Saving. + CanSave = false; SavedConfigurationData = ConfigurationData; SetConfigure(); } @@ -235,6 +244,13 @@ protected virtual bool IsUserLoggedIn() return false; } + if (!DeveloperIdLoginRequired) + { + // At least one user is logged in, and this widget does not require a specific + // DeveloperId so we are in a good state. + return true; + } + if (string.IsNullOrEmpty(DeveloperLoginId)) { // User has not yet chosen a DeveloperId, but there is at least one available, so the @@ -251,6 +267,19 @@ protected virtual bool IsUserLoggedIn() return false; } + public virtual string GetLoadingMessage() + { + if (string.IsNullOrEmpty(LoadingMessage)) + { + return EmptyJson; + } + + return new JsonObject + { + { "loadingMessage", LoadingMessage }, + }.ToJsonString(); + } + protected DeveloperId.DeveloperId? GetDevId(string login) { var devIdProvider = DeveloperIdProvider.GetInstance(); @@ -283,7 +312,7 @@ public void UpdateActivityState() return; } - if (!Pinned || !CanSave) + if (SupportsCustomization && (!Pinned || !CanSave)) { SetConfigure(); return; diff --git a/src/AzureExtension/Widgets/Enums/WidgetDataState.cs b/src/AzureExtension/Widgets/Enums/WidgetDataState.cs index c229a3b..09cf84f 100644 --- a/src/AzureExtension/Widgets/Enums/WidgetDataState.cs +++ b/src/AzureExtension/Widgets/Enums/WidgetDataState.cs @@ -8,6 +8,7 @@ public enum WidgetDataState Unknown, Requested, // Request is out, waiting on a response. Current data is stale. Okay, // Received and updated data, stable state. - Failed, // Failed retrieving data. + FailedUpdate, // Failed updating data. + FailedRead, // Failed to read the data. Disposed, // DataManager has been disposed and should not be used. } diff --git a/src/AzureExtension/Widgets/Templates/AzureLoadingTemplate.json b/src/AzureExtension/Widgets/Templates/AzureLoadingTemplate.json index 9722dcc..d59fd7e 100644 --- a/src/AzureExtension/Widgets/Templates/AzureLoadingTemplate.json +++ b/src/AzureExtension/Widgets/Templates/AzureLoadingTemplate.json @@ -8,7 +8,7 @@ "items": [ { "type": "TextBlock", - "text": "%Widget_Template/Loading%", + "text": "${if(loadingMessage, loadingMessage, '%Widget_Template/Loading%')}", "wrap": true, "weight": "bolder", "horizontalAlignment": "Center" diff --git a/src/AzureExtension/Widgets/WidgetImpl.cs b/src/AzureExtension/Widgets/WidgetImpl.cs index 8b30da3..9cc7d9e 100644 --- a/src/AzureExtension/Widgets/WidgetImpl.cs +++ b/src/AzureExtension/Widgets/WidgetImpl.cs @@ -19,6 +19,8 @@ public WidgetImpl() protected ILogger Log => _log.Value; + protected bool SupportsCustomization { get; set; } = true; + protected string Name => GetType().Name; protected string Id { get; set; } = string.Empty; diff --git a/src/AzureExtension/Widgets/WidgetProvider.cs b/src/AzureExtension/Widgets/WidgetProvider.cs index 79dd5f0..5af7b2d 100644 --- a/src/AzureExtension/Widgets/WidgetProvider.cs +++ b/src/AzureExtension/Widgets/WidgetProvider.cs @@ -25,7 +25,8 @@ public WidgetProvider() _log.Debug("Provider Constructed"); _widgetDefinitionRegistry.Add("Azure_QueryList", new WidgetImplFactory()); _widgetDefinitionRegistry.Add("Azure_QueryTiles", new WidgetImplFactory()); - _widgetDefinitionRegistry.Add("Azure_PullRequests", new WidgetImplFactory()); + _widgetDefinitionRegistry.Add("Azure_PullRequests", new WidgetImplFactory()); + _widgetDefinitionRegistry.Add("Azure_MyPRs", new WidgetImplFactory()); RecoverRunningWidgets(); } diff --git a/src/AzureExtensionServer/Package-Can.appxmanifest b/src/AzureExtensionServer/Package-Can.appxmanifest index e517a13..6732741 100644 --- a/src/AzureExtensionServer/Package-Can.appxmanifest +++ b/src/AzureExtensionServer/Package-Can.appxmanifest @@ -184,6 +184,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AzureExtensionServer/Package-Dev.appxmanifest b/src/AzureExtensionServer/Package-Dev.appxmanifest index 902ed1f..6d415e2 100644 --- a/src/AzureExtensionServer/Package-Dev.appxmanifest +++ b/src/AzureExtensionServer/Package-Dev.appxmanifest @@ -184,6 +184,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AzureExtensionServer/Package.appxmanifest b/src/AzureExtensionServer/Package.appxmanifest index 9a679a4..0890533 100644 --- a/src/AzureExtensionServer/Package.appxmanifest +++ b/src/AzureExtensionServer/Package.appxmanifest @@ -184,6 +184,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +