diff --git a/src/apireview.net/App.razor b/src/apireview.net/App.razor index 9ecbfbd..9b2a471 100644 --- a/src/apireview.net/App.razor +++ b/src/apireview.net/App.razor @@ -1,19 +1,48 @@ - - - - - - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
-
\ No newline at end of file +@inject IHostEnvironment Env + + + + + + + .NET API Review + + + + + + + + + + + +
+ @if (Env.IsDevelopment()) + { + + An unhandled exception has occurred. See browser dev tools for details. + + } + else + { + + An error has occurred. This app may no longer respond until reloaded. + + } + Reload + 🗙 +
+ + + + + + \ No newline at end of file diff --git a/src/apireview.net/Controllers/AccountController.cs b/src/apireview.net/Controllers/AccountController.cs deleted file mode 100644 index 86881dc..0000000 --- a/src/apireview.net/Controllers/AccountController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using AspNet.Security.OAuth.GitHub; - -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Mvc; - -namespace ApiReviewDotNet.Controllers; - -public class AccountController : Controller -{ - [HttpGet("signin")] - public IActionResult SignIn(string returnUrl) - { - return Challenge( - new AuthenticationProperties - { - RedirectUri = "/" + returnUrl - }, - GitHubAuthenticationDefaults.AuthenticationScheme - ); - } - - [HttpGet("signout")] - [HttpPost("signout")] - public new IActionResult SignOut() - { - return SignOut( - new AuthenticationProperties - { - RedirectUri = "/" - }, - CookieAuthenticationDefaults.AuthenticationScheme - ); - } -} diff --git a/src/apireview.net/Data/TimeZoneService.cs b/src/apireview.net/Data/TimeZoneService.cs deleted file mode 100644 index 0a34e0e..0000000 --- a/src/apireview.net/Data/TimeZoneService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.JSInterop; - -namespace ApiReviewDotNet.Data; - -public sealed class TimeZoneService -{ - private readonly IJSRuntime _jsRuntime; - - private TimeSpan? _userOffset; - - public TimeZoneService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - - public async ValueTask ToLocalAsync(DateTimeOffset dateTime) - { - if (_userOffset is null) - { - int offsetInMinutes = await _jsRuntime.InvokeAsync("getTimezoneOffset"); - _userOffset = TimeSpan.FromMinutes(-offsetInMinutes); - } - - return dateTime.ToOffset(_userOffset.Value); - } -} diff --git a/src/apireview.net/Pages/Backlog.razor b/src/apireview.net/Pages/Backlog.razor index d1a5228..5ff0d82 100644 --- a/src/apireview.net/Pages/Backlog.razor +++ b/src/apireview.net/Pages/Backlog.razor @@ -7,80 +7,90 @@ - +
+ @if (SelectedGroup != RepositoryGroupService.Default) + { + + } + @foreach ((string k, bool v) in _milestones.Where(m => m.Value)) + { + + } + +
- @{ - bool isChecked = _checkedIssues.Count == Issues.Count; - } - +
- @if (_checkedIssues.Count == 0) - { -

- @{ - int numberOfOrgs = Issues.Select(i => i.Owner).Distinct().Count(); - int numberOfRepos = Issues.Select(i => i.Repo).Distinct().Count(); - int visibleIssues = VisibleIssues.Count(IsVisible); - } +

+ @{ + int numberOfOrgs = Issues.Select(i => i.Owner).Distinct().Count(); + int numberOfRepos = Issues.Select(i => i.Repo).Distinct().Count(); + int visibleIssues = VisibleIssues.Count(IsVisible); + } - @if (visibleIssues == Issues.Count) - { - - @Issues.Count issues across @numberOfOrgs orgs and @numberOfRepos repos - - } - else - { - - @visibleIssues of @Issues.Count issues across @numberOfOrgs orgs and @numberOfRepos repos - - } -

- } - else - { -
@if (_milestones is not null) {
} @@ -95,11 +105,28 @@ continue; string background = issue.IsBlocking ? "gh-issue-blocking" : ""; + string reviewerLinks = string.Join(" ", issue.Reviewers.Select(r => $"[{r.Name}](https://github.com/{r.GitHubUserName})")); -
+ string issueMarkdown = $"* [{issue.IdFull}]({issue.Url}): {issue.Title}\n"; + if (issue.Reviewers.Any()) + { + string reviewerMentions = string.Join(" ", issue.Reviewers.Select(r => { + string guid = Guid.NewGuid().ToString("N").ToUpper(); + return $"@{r.Name}"; + })); + issueMarkdown += $" - {reviewerMentions}\n"; + } + string issueHtml = Markdig.Markdown.ToHtml(issueMarkdown); + +
- +
@issue.Title.HighlightCode() @@ -135,7 +162,149 @@
- +
+ + +
+ + diff --git a/src/apireview.net/Pages/Backlog.razor.cs b/src/apireview.net/Pages/Backlog.razor.cs index b25d217..a44d55d 100644 --- a/src/apireview.net/Pages/Backlog.razor.cs +++ b/src/apireview.net/Pages/Backlog.razor.cs @@ -1,20 +1,14 @@ -using System.Text; - -using ApiReviewDotNet.Data; +using ApiReviewDotNet.Data; using ApiReviewDotNet.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; -using Microsoft.JSInterop; namespace ApiReviewDotNet.Pages; public sealed partial class Backlog : IDisposable { - [Inject] - private IJSRuntime JSRuntime { get; set; } = null!; - [Inject] public NavigationManager NavigationManager { get; set; } = null!; @@ -27,38 +21,14 @@ public sealed partial class Backlog : IDisposable private RepositoryGroup _selectedGroup = null!; private string _filter = null!; private SortedDictionary _milestones = null!; - private readonly HashSet _checkedIssues = new(); - private Task? _refresh; - private RepositoryGroup SelectedGroup - { - get => _selectedGroup; - set - { - if (_selectedGroup != value) - { - _selectedGroup = value; - ChangeUrl(); - } - } - } - - public string Filter - { - get => _filter; - set - { - if (_filter != value) - { - _filter = value; - ChangeUrl(); - } - } - } + public RepositoryGroup SelectedGroup => _selectedGroup; + public string Filter => _filter; public IReadOnlyList Issues => IssueService.Issues; public IEnumerable VisibleIssues => Issues.Where(IsVisible); - public IEnumerable SelectedIssues => VisibleIssues.Where(_checkedIssues.Contains); + + public string CurrentPath => NavigationManager.ToAbsoluteUri(NavigationManager.Uri).AbsolutePath; public int GetRank(ApiReviewIssue issue) { @@ -70,7 +40,7 @@ public int GetRank(ApiReviewIssue issue) return -1; } - + protected override void OnInitialized() { _selectedGroup = RepositoryGroupService.Default; @@ -91,7 +61,13 @@ protected override void OnInitialized() if (queryParameters.TryGetValue("q", out StringValues q)) _filter = q!; - if (queryParameters.TryGetValue("m", out StringValues selectedMilestones)) + // m_none=1 means no milestones selected; m=value means specific milestones; neither means all (default). + if (queryParameters.ContainsKey("m_none")) + { + foreach (string m in _milestones.Keys.ToArray()) + _milestones[m] = false; + } + else if (queryParameters.TryGetValue("m", out StringValues selectedMilestones)) { foreach (string m in _milestones.Keys.ToArray()) _milestones[m] = false; @@ -111,38 +87,9 @@ public void Dispose() IssueService.Changed -= IssuesChanged; } - private async void ChangeUrl() - { - string query = ""; - - if (SelectedGroup != RepositoryGroupService.Default) - query += $"?g={Uri.EscapeDataString(SelectedGroup.Name)}"; - - if (!string.IsNullOrEmpty(Filter)) - query += $"?q={Uri.EscapeDataString(Filter)}"; - - IEnumerable selectedMilestones = _milestones.Where(m => m.Value).Select(kv => kv.Key); - if (selectedMilestones.Count() != _milestones.Count) - { - foreach (string m in selectedMilestones) - query += $"&m={Uri.EscapeDataString(m)}"; - } - - string uri = new UriBuilder(NavigationManager.Uri) - { - Query = query - }.ToString(); - - await JSRuntime.InvokeVoidAsync("Blazor.navigateTo", - uri.ToString(), - /* forceLoad */ false, - /* replace */ true); - } - private void LoadData() { _milestones = CreateMilestones(Issues, _milestones); - _checkedIssues.Clear(); } private async void IssuesChanged(object? sender, EventArgs e) @@ -202,91 +149,4 @@ private SortedDictionary CreateMilestones(IReadOnlyList@{reviewer.Name}"); - } - } - - sb.AppendLine(); - } - } - - return sb.ToString(); - } - - private void CheckAllIssues(bool value) - { - if (value) - _checkedIssues.UnionWith(VisibleIssues); - else - _checkedIssues.ExceptWith(VisibleIssues); - } - - private void CheckIssue(ApiReviewIssue issue, bool value) - { - if (value) - _checkedIssues.Add(issue); - else - _checkedIssues.Remove(issue); - } - - public bool CanRefresh => _refresh is null; - - public Task RefreshAsync() - { - return (_refresh = RefreshAsyncCore()); - - async Task RefreshAsyncCore() - { - await IssueService.ReloadAsync(); - _refresh = null; - } - } } diff --git a/src/apireview.net/Pages/Publish.razor b/src/apireview.net/Pages/Publish.razor index a08ff32..a5ffcbe 100644 --- a/src/apireview.net/Pages/Publish.razor +++ b/src/apireview.net/Pages/Publish.razor @@ -1,4 +1,5 @@ @page "/publish" +@rendermode InteractiveServer
diff --git a/src/apireview.net/Pages/Schedule.razor b/src/apireview.net/Pages/Schedule.razor index d59a4f0..075e969 100644 --- a/src/apireview.net/Pages/Schedule.razor +++ b/src/apireview.net/Pages/Schedule.razor @@ -1,17 +1,17 @@ @page "/schedule" @using ApiReviewDotNet.Services.Calendar -@if (CurrentDate is not null) +@if (Cells.Length > 0) {
- - - -

@CurrentDate.Value.ToString("MMMM yyyy")

+ + Today + +

@CurrentDate.ToString("MMMM yyyy")

} -@if (Cells is not null) +@if (Cells.Length > 0) {
Sunday
@@ -31,7 +31,7 @@ ? @cell.DateTime.ToString("MMM d") : @cell.DateTime.Day.ToString(); -
+
@dayText
@if (cell.Entries.Any()) @@ -39,7 +39,7 @@
    @foreach (CalendarEntry entry in cell.Entries) { -
  • @entry.Start.ToString("t") @entry.Title
  • +
  • @entry.Title
  • }
} @@ -52,3 +52,38 @@ Videos are streamed to the .NET Foundation YouTube channel. This calendar is available as an .ics file you can add to your favorite calendar app.

+ + diff --git a/src/apireview.net/Pages/Schedule.razor.cs b/src/apireview.net/Pages/Schedule.razor.cs index 31cb059..2f21270 100644 --- a/src/apireview.net/Pages/Schedule.razor.cs +++ b/src/apireview.net/Pages/Schedule.razor.cs @@ -1,5 +1,4 @@  -using ApiReviewDotNet.Data; using ApiReviewDotNet.Services.Calendar; using Microsoft.AspNetCore.Components; @@ -8,59 +7,34 @@ namespace ApiReviewDotNet.Pages; public sealed partial class Schedule { - private DateTimeOffset? Today { get; set; } - private DateTimeOffset? CurrentDate { get; set; } + private static readonly TimeZoneInfo PacificTime = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); - private CalendarCell[] Cells { get; set; } = Array.Empty(); + [SupplyParameterFromQuery(Name = "month")] + public string? MonthParam { get; set; } - [Inject] - public TimeZoneService TimeZoneService { get; set; } = null!; + private DateTimeOffset Today { get; set; } + private DateTimeOffset CurrentDate { get; set; } + private CalendarCell[] Cells { get; set; } = Array.Empty(); [Inject] public CalendarService CalendarService { get; set; } = null!; - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) - await UpdateCellsAsync(); - } + DateTimeOffset nowPacific = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, PacificTime); + Today = new DateTimeOffset(nowPacific.Year, nowPacific.Month, nowPacific.Day, 0, 0, 0, nowPacific.Offset); - private async Task UpdateCellsAsync() - { - if (Today is null) + if (!string.IsNullOrEmpty(MonthParam) && + DateTime.TryParseExact(MonthParam, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out DateTime parsed)) { - DateTimeOffset userDateTime = await TimeZoneService.ToLocalAsync(DateTime.UtcNow); - Today = new DateTimeOffset(userDateTime.Year, userDateTime.Month, userDateTime.Day, 0, 0, 0, 0, userDateTime.Offset); + TimeSpan offset = PacificTime.GetUtcOffset(new DateTime(parsed.Year, parsed.Month, 1)); + CurrentDate = new DateTimeOffset(parsed.Year, parsed.Month, 1, 0, 0, 0, offset); } - - if (CurrentDate is null) - CurrentDate = Today; - - Cells = (await CalendarService.GetCellsAsync(CurrentDate.Value)).ToArray(); - StateHasChanged(); - } - - private async Task TodayAsync() - { - CurrentDate = null; - await UpdateCellsAsync(); - } - - private async Task PreviousMonthAsync() - { - if (CurrentDate is not null) + else { - CurrentDate = CurrentDate.Value.AddMonths(-1); - await UpdateCellsAsync(); + CurrentDate = Today; } - } - private async Task NextMonthAsync() - { - if (CurrentDate is not null) - { - CurrentDate = CurrentDate.Value.AddMonths(1); - await UpdateCellsAsync(); - } + Cells = (await CalendarService.GetCellsAsync(CurrentDate)).ToArray(); } } diff --git a/src/apireview.net/Pages/_Host.cshtml b/src/apireview.net/Pages/_Host.cshtml deleted file mode 100644 index 2d000f9..0000000 --- a/src/apireview.net/Pages/_Host.cshtml +++ /dev/null @@ -1,67 +0,0 @@ -@page "/" -@namespace ApiReviewDotNet.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@{ - Layout = null; -} - - - - - - - .NET API Review - - - - - - - - - - - - -
- - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - Reload - 🗙 -
- - - - - - - - diff --git a/src/apireview.net/Program.cs b/src/apireview.net/Program.cs index a677818..0f88a6c 100644 --- a/src/apireview.net/Program.cs +++ b/src/apireview.net/Program.cs @@ -8,20 +8,20 @@ using ApiReviewDotNet.Services.Ospo; using ApiReviewDotNet.Services.YouTube; +using AspNet.Security.OAuth.GitHub; using Azure.Identity; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Octokit.Webhooks; using Octokit.Webhooks.AspNetCore; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorPages().AddJsonOptions(o => -{ - o.JsonSerializerOptions.Converters.Add(new TimeSpanJsonConverter()); -}); -builder.Services.AddServerSideBlazor(); -builder.Services.AddControllers(); +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -34,7 +34,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddHostedService(); //builder.Configuration.AddAzureKeyVault( // new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"), @@ -43,7 +43,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { @@ -64,7 +63,7 @@ RepositoryGroupService groupService = context.HttpContext.RequestServices.GetRequiredService(); GitHubMembershipService membershipService = context.HttpContext.RequestServices.GetRequiredService(); - string accessToken = context.AccessToken; + string? accessToken = context.AccessToken; string orgName = ApiReviewConstants.ApiApproverOrgName; IReadOnlyList teamSlugs = groupService.ApproverTeamSlugs; if (accessToken is not null && context.Identity?.Name is not null) @@ -82,10 +81,6 @@ WebApplication app = builder.Build(); -// Warm up services -RefreshService refreshService = app.Services.GetRequiredService(); -await refreshService.StartAsync(); - if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); @@ -99,16 +94,39 @@ app.UseHostRedirection("apireview.azurewebsites.net", "apireview.net"); app.UseHttpsRedirection(); -app.UseStaticFiles(); - -app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapStaticAssets(); + +app.MapGet("/signin", (string? returnUrl) => + Results.Challenge( + new AuthenticationProperties { RedirectUri = "/" + returnUrl }, + [GitHubAuthenticationDefaults.AuthenticationScheme])); + +app.MapGet("/signout", () => + Results.SignOut( + new AuthenticationProperties { RedirectUri = "/" }, + [CookieAuthenticationDefaults.AuthenticationScheme])); + +app.MapPost("/signout", () => + Results.SignOut( + new AuthenticationProperties { RedirectUri = "/" }, + [CookieAuthenticationDefaults.AuthenticationScheme])); + +app.MapPost("/admin/force-refresh", + [Authorize(Roles = ApiReviewConstants.ApiApproverRole)] + [RequireAntiforgeryToken] + async (IssueService issueService) => + { + await issueService.ReloadAsync(); + return Results.Redirect("/"); + }); -app.MapBlazorHub(); -app.MapDefaultControllerRoute(); app.MapGitHubWebhooks(); -app.MapFallbackToPage("/_Host"); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); app.Run(); diff --git a/src/apireview.net/Properties/launchSettings.json b/src/apireview.net/Properties/launchSettings.json index 2fb969f..169e8ff 100644 --- a/src/apireview.net/Properties/launchSettings.json +++ b/src/apireview.net/Properties/launchSettings.json @@ -10,7 +10,6 @@ "profiles": { "ApiReviewDotNet": { "commandName": "Project", - "dotnetRunMessages": "true", "launchBrowser": true, "hotReloadProfile": "aspnetcore", "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/src/apireview.net/Routes.razor b/src/apireview.net/Routes.razor new file mode 100644 index 0000000..58c7ee6 --- /dev/null +++ b/src/apireview.net/Routes.razor @@ -0,0 +1,17 @@ + + + + + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/apireview.net/Services/RefreshService.cs b/src/apireview.net/Services/RefreshService.cs index 9837514..d0331dd 100644 --- a/src/apireview.net/Services/RefreshService.cs +++ b/src/apireview.net/Services/RefreshService.cs @@ -1,9 +1,11 @@ using ApiReviewDotNet.Services.GitHub; using ApiReviewDotNet.Services.Ospo; +using Microsoft.Extensions.Hosting; + namespace ApiReviewDotNet.Services; -public sealed class RefreshService +public sealed class RefreshService : BackgroundService { private static readonly TimeSpan _refreshInterval = TimeSpan.FromHours(1); @@ -26,17 +28,30 @@ public RefreshService(ILogger logger, _issueService = issueService; } - public async Task StartAsync() + public override async Task StartAsync(CancellationToken cancellationToken) { await ReloadAsync(); + await base.StartAsync(cancellationToken); + } - _ = Task.Run(async () => { - await Task.Delay(_refreshInterval); - await ReloadAsync(); - }); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(_refreshInterval); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ReloadAsync(); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("RefreshService is stopping."); + } } - public async Task ReloadAsync() + private async Task ReloadAsync() { try { diff --git a/src/apireview.net/Shared/MainLayout.razor b/src/apireview.net/Shared/MainLayout.razor index 1ac30ca..5396de7 100644 --- a/src/apireview.net/Shared/MainLayout.razor +++ b/src/apireview.net/Shared/MainLayout.razor @@ -3,10 +3,10 @@