diff --git a/docs/DevGuide.md b/docs/DevGuide.md index df1906cdd..21e19a3e2 100644 --- a/docs/DevGuide.md +++ b/docs/DevGuide.md @@ -4,6 +4,11 @@ - Be sure to install the `Azure Development => .NET Aspire SDK (Preview)` optional workload in the VS installer - Be sure to install the `ASP.NET and web development` => `.NET 8.0/9.0 WebAssembly Build Tools` 1. Install Docker Desktop: https://www.docker.com/products/docker-desktop +1. Configure git to support long paths: + ```ps1 + git config --system core.longpaths true # you will need elevated shell for this one + git config --global core.longpaths true + ``` 1. Install SQL Server Express: https://www.microsoft.com/en-us/sql-server/sql-server-downloads 1. Install Entity Framework Core CLI by running `dotnet tool install --global dotnet-ef` 1. Build the `src\Maestro\Maestro.Data\Maestro.Data.csproj` project (either from console or from IDE) diff --git a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/PullRequestUpdate.cs b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/PullRequestUpdate.cs index d1e6298ab..01135213f 100644 --- a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/PullRequestUpdate.cs +++ b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/PullRequestUpdate.cs @@ -1,17 +1,26 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using Newtonsoft.Json; namespace Microsoft.DotNet.ProductConstructionService.Client.Models { public partial class PullRequestUpdate { - public PullRequestUpdate() + public PullRequestUpdate(Guid subscriptionId, int buildId) { + SubscriptionId = subscriptionId; + BuildId = buildId; } [JsonProperty("sourceRepository")] public string SourceRepository { get; set; } + + [JsonProperty("subscriptionId")] + public Guid SubscriptionId { get; set; } + + [JsonProperty("buildId")] + public int BuildId { get; set; } } } diff --git a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/TrackedPullRequest.cs b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/TrackedPullRequest.cs index 6957ad9c4..d8a7db8ed 100644 --- a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/TrackedPullRequest.cs +++ b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/Models/TrackedPullRequest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -8,10 +9,16 @@ namespace Microsoft.DotNet.ProductConstructionService.Client.Models { public partial class TrackedPullRequest { - public TrackedPullRequest() + public TrackedPullRequest(bool sourceEnabled, DateTimeOffset lastUpdate, DateTimeOffset lastCheck) { + SourceEnabled = sourceEnabled; + LastUpdate = lastUpdate; + LastCheck = lastCheck; } + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("url")] public string Url { get; set; } @@ -21,6 +28,18 @@ public TrackedPullRequest() [JsonProperty("targetBranch")] public string TargetBranch { get; set; } + [JsonProperty("sourceEnabled")] + public bool SourceEnabled { get; set; } + + [JsonProperty("lastUpdate")] + public DateTimeOffset LastUpdate { get; set; } + + [JsonProperty("lastCheck")] + public DateTimeOffset LastCheck { get; set; } + + [JsonProperty("nextCheck")] + public DateTimeOffset? NextCheck { get; set; } + [JsonProperty("updates")] public List Updates { get; set; } } diff --git a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/PullRequest.cs b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/PullRequest.cs index b2b57294e..1237a9317 100644 --- a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/PullRequest.cs +++ b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/Generated/PullRequest.cs @@ -19,6 +19,11 @@ public partial interface IPullRequest CancellationToken cancellationToken = default ); + Task UntrackPullRequestAsync( + string id, + CancellationToken cancellationToken = default + ); + } internal partial class PullRequest : IServiceOperations, IPullRequest @@ -100,5 +105,66 @@ internal async Task OnGetTrackedPullRequestsFailed(Request req, Response res) Client.OnFailedRequest(ex); throw ex; } + + partial void HandleFailedUntrackPullRequestRequest(RestApiException ex); + + public async Task UntrackPullRequestAsync( + string id, + CancellationToken cancellationToken = default + ) + { + + const string apiVersion = "2020-02-20"; + + var _baseUri = Client.Options.BaseUri; + var _url = new RequestUriBuilder(); + _url.Reset(_baseUri); + _url.AppendPath( + "/api/pull-requests/tracked/{id}".Replace("{id}", Uri.EscapeDataString(Client.Serialize(id))), + false); + + _url.AppendQuery("api-version", Client.Serialize(apiVersion)); + + + using (var _req = Client.Pipeline.CreateRequest()) + { + _req.Uri = _url; + _req.Method = RequestMethod.Delete; + + using (var _res = await Client.SendAsync(_req, cancellationToken).ConfigureAwait(false)) + { + if (_res.Status < 200 || _res.Status >= 300) + { + await OnUntrackPullRequestFailed(_req, _res).ConfigureAwait(false); + } + + + return; + } + } + } + + internal async Task OnUntrackPullRequestFailed(Request req, Response res) + { + string content = null; + if (res.ContentStream != null) + { + using (var reader = new StreamReader(res.ContentStream)) + { + content = await reader.ReadToEndAsync().ConfigureAwait(false); + } + } + + var ex = new RestApiException( + req, + res, + content, + Client.Deserialize(content) + ); + HandleFailedUntrackPullRequestRequest(ex); + HandleFailedRequest(ex); + Client.OnFailedRequest(ex); + throw ex; + } } } diff --git a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/ProductConstructionServiceApi.cs b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/ProductConstructionServiceApi.cs index aa7dca36d..c5cf66cf8 100644 --- a/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/ProductConstructionServiceApi.cs +++ b/src/ProductConstructionService/Microsoft.DotNet.ProductConstructionService.Client/ProductConstructionServiceApi.cs @@ -2,15 +2,23 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Newtonsoft.Json.Linq; -using System.Net; using System.IO; +using System.Net; using System.Net.Http; using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Newtonsoft.Json.Linq; #nullable enable namespace Microsoft.DotNet.ProductConstructionService.Client { + public partial interface IProductConstructionServiceApi + { + Task IsAdmin(CancellationToken cancellationToken = default); + } + public partial class ProductConstructionServiceApi { // Special error handler to consumes the generated MaestroApi code. If this method returns without throwing a specific exception @@ -40,6 +48,33 @@ partial void HandleFailedRequest(RestApiException ex) "Please make sure the PAT you're using is valid."); } } + + public async Task IsAdmin(CancellationToken cancellationToken = default) + { + var url = new RequestUriBuilder(); + url.Reset(Options.BaseUri); + url.AppendPath("/Account", false); + + using (var request = Pipeline.CreateRequest()) + { + request.Uri = url; + request.Method = RequestMethod.Get; + + using (var response = await SendAsync(request, cancellationToken).ConfigureAwait(false)) + { + if (response.Status < 200 || response.Status >= 300 || response.ContentStream == null) + { + throw new RestApiException(request, response, "Invalid response"); + } + + using (var _reader = new StreamReader(response.ContentStream)) + { + var content = await _reader.ReadToEndAsync().ConfigureAwait(false); + return content.Trim() == "Admin"; + } + } + } + } } internal partial class ProductConstructionServiceApiResponseClassifier diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Api/v2020_02_20/Controllers/PullRequestController.cs b/src/ProductConstructionService/ProductConstructionService.Api/Api/v2020_02_20/Controllers/PullRequestController.cs index 6d1125dca..6b7e07e93 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/Api/v2020_02_20/Controllers/PullRequestController.cs +++ b/src/ProductConstructionService/ProductConstructionService.Api/Api/v2020_02_20/Controllers/PullRequestController.cs @@ -6,9 +6,11 @@ using Maestro.Data; using Microsoft.AspNetCore.ApiVersioning; using Microsoft.AspNetCore.ApiVersioning.Swashbuckle; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.DotNet.DarcLib.Helpers; using Microsoft.EntityFrameworkCore; +using ProductConstructionService.Api.Configuration; using ProductConstructionService.Common; using ProductConstructionService.DependencyFlow; @@ -57,10 +59,11 @@ public PullRequestController( [ValidateModelState] public async Task GetTrackedPullRequests() { - var cache = _cacheFactory.Create(nameof(InProgressPullRequest) + "_"); + var keyPrefix = nameof(InProgressPullRequest) + "_"; + var cache = _cacheFactory.Create(keyPrefix); var prs = new List(); - await foreach (var key in cache.GetKeysAsync(nameof(InProgressPullRequest) + "_*")) + await foreach (var key in cache.GetKeysAsync(keyPrefix + "*")) { var pr = await _cacheFactory .Create(key, includeTypeInKey: false) @@ -99,19 +102,36 @@ public async Task GetTrackedPullRequests() var updates = subscriptions .Select(update => new PullRequestUpdate( TurnApiUrlToWebsite(update.SourceRepository, org, repoName), + pr.ContainedSubscriptions.First(s => s.SubscriptionId == update.Id).SubscriptionId, pr.ContainedSubscriptions.First(s => s.SubscriptionId == update.Id).BuildId)) .ToList(); prs.Add(new TrackedPullRequest( + key.Replace(keyPrefix, null, StringComparison.InvariantCultureIgnoreCase), TurnApiUrlToWebsite(pr.Url, org, repoName), sampleSub?.Channel?.Name, sampleSub?.TargetBranch, + sampleSub?.SourceEnabled ?? false, + pr.LastUpdate, + pr.LastCheck, + pr.NextCheck, updates)); } return Ok(prs.AsQueryable()); } + [HttpDelete("tracked/{id}")] + [Authorize(Policy = AuthenticationConfiguration.AdminAuthorizationPolicyName)] + [SwaggerApiResponse(HttpStatusCode.OK, Type = typeof(void), Description = "The pull request was successfully untracked")] + [SwaggerApiResponse(HttpStatusCode.NotFound, Type = typeof(void), Description = "The pull request was not found in the list of tracked pull requests")] + [ValidateModelState] + public async Task UntrackPullRequest(string id) + { + var cache = _cacheFactory.Create($"{nameof(InProgressPullRequest)}_{id}", includeTypeInKey: false); + return await cache.TryDeleteAsync() == null ? NotFound() : Ok(); + } + private static string TurnApiUrlToWebsite(string url, string? orgName, string? repoName) { var match = GitHubApiPrUrlRegex().Match(url); @@ -139,12 +159,18 @@ private static string TurnApiUrlToWebsite(string url, string? orgName, string? r } private record TrackedPullRequest( + string Id, string Url, string? Channel, string? TargetBranch, + bool SourceEnabled, + DateTime LastUpdate, + DateTime LastCheck, + DateTime? NextCheck, List Updates); private record PullRequestUpdate( string SourceRepository, + Guid SubscriptionId, int BuildId); } diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Configuration/AuthenticationConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.Api/Configuration/AuthenticationConfiguration.cs index d23132921..63d9d5747 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/Configuration/AuthenticationConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.Api/Configuration/AuthenticationConfiguration.cs @@ -7,7 +7,7 @@ namespace ProductConstructionService.Api.Configuration; -public static class AuthenticationConfiguration +internal static class AuthenticationConfiguration { public const string EntraAuthorizationPolicyName = "Entra"; public const string MsftAuthorizationPolicyName = "msft"; @@ -87,20 +87,18 @@ public static void ConfigureAuthServices(this IServiceCollection services, IConf }); services - .AddAuthorization(options => - { - options.AddPolicy(MsftAuthorizationPolicyName, policy => + .AddAuthorizationBuilder() + .AddPolicy(MsftAuthorizationPolicyName, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes); policy.RequireAuthenticatedUser(); policy.RequireRole(userRole); - }); - options.AddPolicy(AdminAuthorizationPolicyName, policy => + }) + .AddPolicy(AdminAuthorizationPolicyName, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes); policy.RequireAuthenticatedUser(); - policy.RequireRole("Admin"); + policy.RequireRole(adminRole); }); - }); } } diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Controllers/AccountController.cs b/src/ProductConstructionService/ProductConstructionService.Api/Controllers/AccountController.cs index 3e42a7886..e829122d4 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/Controllers/AccountController.cs +++ b/src/ProductConstructionService/ProductConstructionService.Api/Controllers/AccountController.cs @@ -32,4 +32,15 @@ public IActionResult SignIn(string? returnUrl = null) new AuthenticationProperties() { RedirectUri = returnUrl }, OpenIdConnectDefaults.AuthenticationScheme); } + + [HttpGet("/Account")] + [Authorize] + public IActionResult Account() + { +#if DEBUG + return Ok("Admin"); +#else + return Ok(HttpContext.User.IsInRole("Admin") ? "Admin" : "User"); +#endif + } } diff --git a/src/ProductConstructionService/ProductConstructionService.BarViz/App.razor b/src/ProductConstructionService/ProductConstructionService.BarViz/App.razor index 542d6e464..c3d8ea657 100644 --- a/src/ProductConstructionService/ProductConstructionService.BarViz/App.razor +++ b/src/ProductConstructionService/ProductConstructionService.BarViz/App.razor @@ -16,6 +16,10 @@ + + + + @code { private IDisposable? _navigationHandlerRegistration = null; diff --git a/src/ProductConstructionService/ProductConstructionService.BarViz/Code/Services/UserRoleManager.cs b/src/ProductConstructionService/ProductConstructionService.BarViz/Code/Services/UserRoleManager.cs new file mode 100644 index 000000000..2373d16bc --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.BarViz/Code/Services/UserRoleManager.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.ProductConstructionService.Client; + +namespace ProductConstructionService.BarViz.Code.Services; + +public class UserRoleManager(IProductConstructionServiceApi pcsApi) +{ + private readonly Lazy> _isAdmin = new( + () => pcsApi.IsAdmin(), + LazyThreadSafetyMode.ExecutionAndPublication); + + public Task IsAdmin => _isAdmin.Value; +} diff --git a/src/ProductConstructionService/ProductConstructionService.BarViz/Components/PullRequestContextMenu.razor b/src/ProductConstructionService/ProductConstructionService.BarViz/Components/PullRequestContextMenu.razor new file mode 100644 index 000000000..73d99d9ce --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.BarViz/Components/PullRequestContextMenu.razor @@ -0,0 +1,78 @@ +@using Microsoft.DotNet.ProductConstructionService.Client; +@using Microsoft.DotNet.ProductConstructionService.Client.Models; +@using System.ComponentModel.DataAnnotations +@inject IProductConstructionServiceApi PcsApi +@inject IToastService ToastService +@inject UserRoleManager UserRoleManager + + + + + + + + Re-trigger subscription + + + + + + + Untrack + + + + + + + +@code { + private bool _isContextMenuOpen = false; + private bool _isAdmin = false; + + [Parameter, EditorRequired] + public required TrackedPullRequest PullRequest { get; set; } + + [Parameter, EditorRequired] + public required Func Refresh { get; set; } + + protected override async Task OnInitializedAsync() + { + _isAdmin = await UserRoleManager.IsAdmin; + } + + async Task UntrackPullRequest() + { + try + { + await PcsApi.PullRequest.UntrackPullRequestAsync(PullRequest.Id); + await Refresh.Invoke(); + ToastService.ShowSuccess("PR untracked"); + } + catch (Exception e) + { + ToastService.ShowError("Failed to untrack the PR: " + e.ToString()); + } + } + + async Task TriggerSubscription() + { + try + { + foreach (var update in PullRequest.Updates) + { + await PcsApi.Subscriptions.TriggerSubscriptionAsync(update.SubscriptionId); + ToastService.ShowProgress("Subscriptions in the PR triggered"); + } + } + catch + { + ToastService.ShowError("Failed to trigger the subscription"); + } + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.BarViz/Pages/PullRequests.razor b/src/ProductConstructionService/ProductConstructionService.BarViz/Pages/PullRequests.razor index c80998a1e..c9be26cac 100644 --- a/src/ProductConstructionService/ProductConstructionService.BarViz/Pages/PullRequests.razor +++ b/src/ProductConstructionService/ProductConstructionService.BarViz/Pages/PullRequests.razor @@ -1,46 +1,77 @@ @page "/pullrequests" @using Microsoft.DotNet.ProductConstructionService.Client -@using Microsoft.DotNet.ProductConstructionService.Client.Models; +@using Microsoft.DotNet.ProductConstructionService.Client.Models +@using Microsoft.FluentUI.AspNetCore.Components.Extensions @using System.Linq.Expressions -@inject IProductConstructionServiceApi api +@using ProductConstructionService.BarViz.Components +@inject IProductConstructionServiceApi PcsApi Tracked Pull Requests - +
+ +
+ + - - - @context.Url - - - @if (context.Channel != null) - { - @context.Channel - } - else - { - - + + + No pull requests found + + + + @context.Url + + + @(context.Channel ?? "N/A") + + + @(context.TargetBranch ?? "N/A") + + + + @(context.LastUpdate == default ? "N/A" : (DateTime.UtcNow - context.LastUpdate).ToTimeAgo()) - } - - - @if (context.TargetBranch != null) - { - @context.TargetBranch - } - else - { + @if (context.Updates.Count > 0) + { + + @foreach (var update in context.Updates) + { +
+
@update.SourceRepository
+ Build ID: @update.BuildId
+ Subscription ID: @update.SubscriptionId
+
+ } +
+ } +
+ - + @(context.LastCheck == default ? "N/A" : (DateTime.UtcNow - context.LastCheck).ToTimeAgo()) + @if (DateTime.UtcNow - context.LastCheck > TimeSpan.FromHours(1)) + { + + } + @if (!context.NextCheck.HasValue) + { + + } - } - +
+ + + +
@@ -50,21 +81,38 @@ Timer? _timer; GridSort SortBy(Expression> sorter) => GridSort.ByAscending(sorter); + bool _autoRefresh = true; - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { - await LoadDataAsync(); _timer = new Timer(async _ => await LoadDataAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(30)); } - private async Task LoadDataAsync() + async Task LoadDataAsync() { - TrackedPullRequests = (await api.PullRequest.GetTrackedPullRequestsAsync()).AsQueryable(); + TrackedPullRequests = (await PcsApi.PullRequest.GetTrackedPullRequestsAsync()).AsQueryable(); await InvokeAsync(StateHasChanged); } + void SetAutoRefresh(bool value) + { + _autoRefresh = value; + + if (value) + { + OnInitialized(); + } + else + { + _timer?.Dispose(); + } + } + public void Dispose() { - _timer?.Dispose(); + if (_autoRefresh) + { + _timer?.Dispose(); + } } } diff --git a/src/ProductConstructionService/ProductConstructionService.BarViz/Program.cs b/src/ProductConstructionService/ProductConstructionService.BarViz/Program.cs index d2e88dd74..fff69decb 100644 --- a/src/ProductConstructionService/ProductConstructionService.BarViz/Program.cs +++ b/src/ProductConstructionService/ProductConstructionService.BarViz/Program.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.DotNet.ProductConstructionService.Client; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Components.Tooltip; using ProductConstructionService.BarViz; using ProductConstructionService.BarViz.Code.Services; using TextCopy; @@ -29,6 +30,8 @@ builder.Services.AddSingleton(PcsApiFactory.GetAnonymous(PcsApiBaseAddress)); builder.Services.InjectClipboard(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddBlazoredSessionStorage(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/InProgressPullRequest.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/InProgressPullRequest.cs index da6dbe765..67950cdb9 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/InProgressPullRequest.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/InProgressPullRequest.cs @@ -41,4 +41,13 @@ public class InProgressPullRequest : DependencyFlowWorkItem, IPullRequest [DataMember] public bool? SourceRepoNotified { get; set; } + + [DataMember] + public DateTime LastUpdate { get; set; } = DateTime.UtcNow; + + [DataMember] + public DateTime LastCheck { get; set; } = DateTime.UtcNow; + + [DataMember] + public DateTime? NextCheck { get; set; } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs index 33eacbb76..3f124e5c4 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs @@ -501,7 +501,7 @@ private async Task AddDependencyFlowEventsAsync( .ToList(), CoherencyCheckSuccessful = repoDependencyUpdate.CoherencyCheckSuccessful, - CoherencyErrors = repoDependencyUpdate.CoherencyErrors + CoherencyErrors = repoDependencyUpdate.CoherencyErrors, }; if (!string.IsNullOrEmpty(prUrl)) @@ -619,6 +619,7 @@ await AddDependencyFlowEventsAsync( pullRequest.Title = await _pullRequestBuilder.GeneratePRTitleAsync(pr.ContainedSubscriptions, targetBranch); await darcRemote.UpdatePullRequestAsync(pr.Url, pullRequest); + pr.LastUpdate = DateTime.UtcNow; await SetPullRequestCheckReminder(pr, isCodeFlow: update.SubscriptionType == SubscriptionType.DependenciesAndSources); _logger.LogInformation("Pull request '{prUrl}' updated", pr.Url); @@ -787,6 +788,10 @@ private async Task SetPullRequestCheckReminder(InProgressPullRequest prState, bo Url = prState.Url, IsCodeFlow = isCodeFlow }; + + prState.LastCheck = DateTime.UtcNow; + prState.NextCheck = prState.LastCheck + DefaultReminderDelay; + await _pullRequestCheckReminders.SetReminderAsync(reminder, DefaultReminderDelay, isCodeFlow); await _pullRequestState.SetAsync(prState); } @@ -1006,6 +1011,7 @@ private async Task UpdateAssetsAndSources(SubscriptionUpdateWorkItem updat Description = description }); + pullRequest.LastUpdate = DateTime.UtcNow; await SetPullRequestCheckReminder(pullRequest, true); await _pullRequestUpdateReminders.UnsetReminderAsync(true); @@ -1146,6 +1152,7 @@ await AddDependencyFlowEventsAsync( MergePolicyCheckResult.PendingPolicies, prUrl); + inProgressPr.LastUpdate = DateTime.UtcNow; await SetPullRequestCheckReminder(inProgressPr, isCodeFlow); await _pullRequestUpdateReminders.UnsetReminderAsync(isCodeFlow); diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/CompactConsoleLoggerFormatter.cs b/src/ProductConstructionService/ProductConstructionService.ReproTool/CompactConsoleLoggerFormatter.cs new file mode 100644 index 000000000..e3938fa51 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/CompactConsoleLoggerFormatter.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; + +// TODO (https://github.com/dotnet/arcade/issues/8836): Use the formatter from Arcade.Common once we're able to consume latest Arcade + +namespace ProductConstructionService.ReproTool; + +/// +/// Copied over from SimpleConsoleFormatter. Leaves out the logger name and new line, turning +/// info: test[0] +/// Log message +/// Second line of the message +/// +/// into +/// +/// info: Log message +/// Second line of the message +/// +/// Only using SimpleConsoleFormatterOptions.SingleLine didn't help because multi-line messages +/// were put together on a single line so things like stack traces of exceptions were unreadable. +/// +/// See https://github.com/dotnet/runtime/blob/0817e748b7698bef1e812fd74c8a3558b7f86421/src/libraries/Microsoft.Extensions.Logging.Console/src/SimpleConsoleFormatter.cs +/// +public class CompactConsoleLoggerFormatter : ConsoleFormatter +{ + private const string LoglevelPadding = ": "; + private const string DefaultForegroundColor = "\x1B[39m\x1B[22m"; // reset to default foreground color + private const string DefaultBackgroundColor = "\x1B[49m"; // reset to the background color + + public const string FormatterName = "compact"; + + private readonly SimpleConsoleFormatterOptions _options; + private readonly string _messagePadding; + private readonly string _newLineWithMessagePadding; + + public CompactConsoleLoggerFormatter(IOptionsMonitor options) + : base(FormatterName) + { + _options = options.CurrentValue; + _messagePadding = new string(' ', GetLogLevelString(LogLevel.Information).Length + LoglevelPadding.Length + (_options.TimestampFormat?.Length ?? 0)); + _newLineWithMessagePadding = Environment.NewLine + _messagePadding; + } + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + if (logEntry.Formatter == null) + { + return; + } + + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (logEntry.Exception == null && message == null) + { + return; + } + + LogLevel logLevel = logEntry.LogLevel; + var logLevelColors = GetLogLevelConsoleColors(logLevel); + var logLevelString = GetLogLevelString(logLevel); + + if (_options.TimestampFormat != null) + { + var timestamp = DateTimeOffset.Now.ToString(_options.TimestampFormat); + textWriter.Write(timestamp); + } + + WriteColoredMessage(textWriter, logLevelString, logLevelColors.Background, logLevelColors.Foreground); + + textWriter.Write(LoglevelPadding); + + WriteMessage(textWriter, message, false); + + // Example: + // System.InvalidOperationException + // at Namespace.Class.Function() in File:line X + if (logEntry.Exception != null) + { + // exception message + WriteMessage(textWriter, logEntry.Exception.ToString()); + } + } + + private void WriteMessage(TextWriter textWriter, string message, bool includePadding = true) + { + if (message == null) + { + return; + } + + if (includePadding) + { + textWriter.Write(_messagePadding); + } + + textWriter.WriteLine(message.Replace(Environment.NewLine, _newLineWithMessagePadding)); + } + + private static string GetLogLevelString(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => throw new ArgumentOutOfRangeException(nameof(logLevel)) + }; + + private (ConsoleColor? Foreground, ConsoleColor? Background) GetLogLevelConsoleColors(LogLevel logLevel) + { + if (_options.ColorBehavior == LoggerColorBehavior.Disabled) + { + return (null, null); + } + + // We must explicitly set the background color if we are setting the foreground color, + // since just setting one can look bad on the users console. + return logLevel switch + { + LogLevel.Trace => (ConsoleColor.Gray, ConsoleColor.Black), + LogLevel.Debug => (ConsoleColor.Gray, ConsoleColor.Black), + LogLevel.Information => (ConsoleColor.DarkGreen, ConsoleColor.Black), + LogLevel.Warning => (ConsoleColor.Yellow, ConsoleColor.Black), + LogLevel.Error => (ConsoleColor.Black, ConsoleColor.DarkRed), + LogLevel.Critical => (ConsoleColor.White, ConsoleColor.DarkRed), + _ => (null, null) + }; + } + + private static void WriteColoredMessage(TextWriter textWriter, string message, ConsoleColor? background, ConsoleColor? foreground) + { + // Order: backgroundcolor, foregroundcolor, Message, reset foregroundcolor, reset backgroundcolor + if (background.HasValue) + { + textWriter.Write(GetBackgroundColorEscapeCode(background.Value)); + } + + if (foreground.HasValue) + { + textWriter.Write(GetForegroundColorEscapeCode(foreground.Value)); + } + + textWriter.Write(message); + + if (foreground.HasValue) + { + textWriter.Write(DefaultForegroundColor); // reset to default foreground color + } + + if (background.HasValue) + { + textWriter.Write(DefaultBackgroundColor); // reset to the background color + } + } + + private static string GetForegroundColorEscapeCode(ConsoleColor color) => color switch + { + ConsoleColor.Black => "\x1B[30m", + ConsoleColor.DarkRed => "\x1B[31m", + ConsoleColor.DarkGreen => "\x1B[32m", + ConsoleColor.DarkYellow => "\x1B[33m", + ConsoleColor.DarkBlue => "\x1B[34m", + ConsoleColor.DarkMagenta => "\x1B[35m", + ConsoleColor.DarkCyan => "\x1B[36m", + ConsoleColor.Gray => "\x1B[37m", + ConsoleColor.Red => "\x1B[1m\x1B[31m", + ConsoleColor.Green => "\x1B[1m\x1B[32m", + ConsoleColor.Yellow => "\x1B[1m\x1B[33m", + ConsoleColor.Blue => "\x1B[1m\x1B[34m", + ConsoleColor.Magenta => "\x1B[1m\x1B[35m", + ConsoleColor.Cyan => "\x1B[1m\x1B[36m", + ConsoleColor.White => "\x1B[1m\x1B[37m", + _ => DefaultForegroundColor // default foreground color + }; + + private static string GetBackgroundColorEscapeCode(ConsoleColor color) => color switch + { + ConsoleColor.Black => "\x1B[40m", + ConsoleColor.DarkRed => "\x1B[41m", + ConsoleColor.DarkGreen => "\x1B[42m", + ConsoleColor.DarkYellow => "\x1B[43m", + ConsoleColor.DarkBlue => "\x1B[44m", + ConsoleColor.DarkMagenta => "\x1B[45m", + ConsoleColor.DarkCyan => "\x1B[46m", + ConsoleColor.Gray => "\x1B[47m", + _ => DefaultBackgroundColor // Use default background color + }; +} diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/ProductConstructionService.ReproTool.csproj b/src/ProductConstructionService/ProductConstructionService.ReproTool/ProductConstructionService.ReproTool.csproj index 102995a1e..b0749c91a 100644 --- a/src/ProductConstructionService/ProductConstructionService.ReproTool/ProductConstructionService.ReproTool.csproj +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/ProductConstructionService.ReproTool.csproj @@ -6,10 +6,12 @@ enable enable False + d1deb1c4-c45b-4d37-8e76-cc23515470a4 + diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/Program.cs b/src/ProductConstructionService/ProductConstructionService.ReproTool/Program.cs index e0ab8ebe5..c5c907161 100644 --- a/src/ProductConstructionService/ProductConstructionService.ReproTool/Program.cs +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/Program.cs @@ -2,13 +2,21 @@ // The .NET Foundation licenses this file to you under the MIT license. using CommandLine; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ProductConstructionService.ReproTool; Parser.Default.ParseArguments(args) .WithParsed(o => { - ServiceCollection services = new ServiceCollection(); + IConfiguration userSecrets = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + o.GitHubToken ??= userSecrets["GITHUB_TOKEN"]; + o.GitHubToken ??= Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + ArgumentNullException.ThrowIfNull(o.GitHubToken, "GitHub must be provided via env variable, user secret or an option"); + + var services = new ServiceCollection(); services.RegisterServices(o); diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproTool.cs b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproTool.cs index 1ce9a520d..f55b99663 100644 --- a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproTool.cs +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproTool.cs @@ -145,30 +145,41 @@ internal async Task ReproduceCodeFlow() await TriggerSubscriptionAsync(testSubscription.Value); - logger.LogInformation("Code flow successfully recreated. Press enter to finish and cleanup"); - Console.ReadLine(); - if (options.SkipCleanup) { logger.LogInformation("Skipping cleanup. If you want to re-trigger the reproduced subscription run \"darc trigger-subscriptions --ids {subscriptionId}\" --bar-uri {barUri}", testSubscription.Value, ProductConstructionServiceApiOptions.PcsLocalUri); + return; + } + + logger.LogInformation("Code flow successfully recreated. Press enter to finish and cleanup"); + Console.ReadLine(); + + // Cleanup + if (isForwardFlow) + { + await DeleteDarcPRBranchAsync(VmrForkRepoName, vmrTmpBranch.Value); } else { - // Cleanup - await DeleteDarcPRBranchAsync( - isForwardFlow ? VmrForkRepoName : productRepoUri.Split('/').Last(), - isForwardFlow ? vmrTmpBranch.Value : productRepoTmpBranch.Value); + await DeleteDarcPRBranchAsync(productRepoUri.Split('/').Last(), productRepoTmpBranch.Value); } } private async Task DeleteDarcPRBranchAsync(string repo, string targetBranch) { var branch = (await ghClient.Repository.Branch.GetAll(MaestroAuthTestOrgName, repo)) - .FirstOrDefault(branch => branch.Name.StartsWith($"{DarcPRBranchPrefix}-{targetBranch}")) - ?? throw new Exception($"Couldn't find darc PR branch targeting branch {targetBranch}"); - await DeleteGitHubBranchAsync(repo, branch.Name); + .FirstOrDefault(branch => branch.Name.StartsWith($"{DarcPRBranchPrefix}-{targetBranch}")); + + if (branch == null) + { + logger.LogWarning("Couldn't find darc PR branch targeting branch {targetBranch}", targetBranch); + } + else + { + await DeleteGitHubBranchAsync(repo, branch.Name); + } } private async Task AddRepositoryToBarIfMissingAsync(string repositoryName) @@ -193,7 +204,7 @@ private async Task CreateBuildAsync(string repositoryUrl, string branch, commit: commit, azureDevOpsAccount: "test", azureDevOpsProject: "test", - azureDevOpsBuildNumber: $"{DateTime.UtcNow.ToString("yyyyMMdd")}.{new Random().Next(1, 75)}", + azureDevOpsBuildNumber: $"{DateTime.UtcNow:yyyyMMdd}.{new Random().Next(1, 75)}", azureDevOpsRepository: repositoryUrl, azureDevOpsBranch: branch, released: false, @@ -207,14 +218,16 @@ private async Task CreateBuildAsync(string repositoryUrl, string branch, return build; } - private List CreateAssetDataFromBuild(Build build) + private static List CreateAssetDataFromBuild(Build build) { - return build.Assets.Select(asset => new AssetData(false) - { - Name = asset.Name, - Version = asset.Version, - Locations = asset.Locations.Select(location => new AssetLocationData(location.Type) { Location = location.Location}).ToList() - }).ToList(); + return build.Assets + .Select(asset => new AssetData(false) + { + Name = asset.Name, + Version = asset.Version, + Locations = asset.Locations?.Select(location => new AssetLocationData(location.Type) { Location = location.Location}).ToList() + }) + .ToList(); } private async Task TriggerSubscriptionAsync(string subscriptionId) @@ -252,12 +265,13 @@ private async Task UpdateRemoteVmrForkFileAsync(string branch, string productRep logger.LogInformation("Updating file {file} on branch {branch} in the VMR fork", filePath, branch); // Fetch remote file and replace the product repo URI with the repo we're testing on var sourceMappingsFile = (await ghClient.Repository.Content.GetAllContentsByRef( - MaestroAuthTestOrgName, - VmrForkRepoName, - filePath, - branch)).FirstOrDefault() ?? - throw new Exception($"Failed to find file {SourceMappingsPath} in {MaestroAuthTestOrgName}" + - $"/{VmrForkRepoName} on branch {SourceMappingsPath}"); + MaestroAuthTestOrgName, + VmrForkRepoName, + filePath, + branch)) + .FirstOrDefault() + ?? throw new Exception($"Failed to find file {SourceMappingsPath} in {MaestroAuthTestOrgName}" + + $"/{VmrForkRepoName} on branch {SourceMappingsPath}"); // Replace the product repo uri with the forked one var updatedSourceMappings = sourceMappingsFile.Content.Replace(productRepoUri, productRepoForkUri); @@ -312,7 +326,7 @@ private async Task SyncForkAsync(string originOrg, string repoName, string branc private async Task> CreateTmpBranchAsync(string repoName, string originalBranch, bool skipCleanup) { - var newBranchName = $"repro/{Guid.NewGuid().ToString()}"; + var newBranchName = $"repro/{Guid.NewGuid()}"; logger.LogInformation("Creating temporary branch {branch} in {repo}", newBranchName, $"{MaestroAuthTestOrgName}/{repoName}"); var baseBranch = await ghClient.Git.Reference.Get(MaestroAuthTestOrgName, repoName, $"heads/{originalBranch}"); diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolConfiguration.cs index e90611054..f3c467e7e 100644 --- a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolConfiguration.cs @@ -15,6 +15,7 @@ using Microsoft.DotNet.ProductConstructionService.Client; using GitHubClient = Octokit.GitHubClient; using Octokit; +using Microsoft.Extensions.Logging.Console; namespace ProductConstructionService.ReproTool; internal static class ReproToolConfiguration @@ -23,10 +24,15 @@ internal static class ReproToolConfiguration private const string MaestroProdUri = "https://maestro.dot.net"; internal const string PcsLocalUri = "https://localhost:53180"; - internal static ServiceCollection RegisterServices(this ServiceCollection services, ReproToolOptions options) + internal static ServiceCollection RegisterServices( + this ServiceCollection services, + ReproToolOptions options) { services.AddSingleton(options); - services.AddLogging(builder => builder.AddConsole()); + services.AddLogging(b => b + .AddConsole(o => o.FormatterName = CompactConsoleLoggerFormatter.FormatterName) + .AddConsoleFormatter() + .SetMinimumLevel(LogLevel.Information)); services.AddSingleton(sp => sp.GetRequiredService>()); services.AddSingleton(sp => new BarApiClient( diff --git a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolOptions.cs b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolOptions.cs index 6dbcc5e40..f520e96a7 100644 --- a/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolOptions.cs +++ b/src/ProductConstructionService/ProductConstructionService.ReproTool/ReproToolOptions.cs @@ -10,8 +10,8 @@ internal class ReproToolOptions [Option('s', "subscription", HelpText = "Subscription that's getting reproduced", Required = true)] public required string Subscription { get; init; } - [Option("github-token", HelpText = "GitHub token", Required = true)] - public required string GitHubToken { get; init; } + [Option("github-token", HelpText = "GitHub token", Required = false)] + public string? GitHubToken { get; set; } [Option("commit", HelpText = "Commit to flow. Use when not flowing a build. If neither commit or build is specified, the latest commit in the subscription's source repository is flown", Required = false)] public string? Commit { get; init; } diff --git a/test/ProductConstructionService.DependencyFlow.Tests/UpdaterTests.cs b/test/ProductConstructionService.DependencyFlow.Tests/UpdaterTests.cs index 4cef21dd4..86020f1a1 100644 --- a/test/ProductConstructionService.DependencyFlow.Tests/UpdaterTests.cs +++ b/test/ProductConstructionService.DependencyFlow.Tests/UpdaterTests.cs @@ -81,6 +81,15 @@ public void UpdaterTests_SetUp() [TearDown] public void UpdaterTests_TearDown() { + foreach (var pair in Cache.Data) + { + if (pair.Value is InProgressPullRequest pr) + { + pr.LastCheck = (ExpectedCacheState[pair.Key] as InProgressPullRequest)!.LastCheck; + pr.LastUpdate = (ExpectedCacheState[pair.Key] as InProgressPullRequest)!.LastUpdate; + pr.NextCheck = (ExpectedCacheState[pair.Key] as InProgressPullRequest)!.NextCheck; + } + } Cache.Data.Should().BeEquivalentTo(ExpectedCacheState); Reminders.Reminders.Should().BeEquivalentTo(ExpectedReminders); }