diff --git a/arcade-services.sln b/arcade-services.sln index 916acf4af6..9b923fd3bc 100644 --- a/arcade-services.sln +++ b/arcade-services.sln @@ -214,6 +214,7 @@ Global {37D70EEA-9621-44EB-921A-5D303917F851}.Release|x86.Build.0 = Release|Any CPU {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|Any CPU.ActiveCfg = Debug|x64 {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|Any CPU.Build.0 = Debug|x64 + {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|Any CPU.Deploy.0 = Debug|x64 {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|x64.ActiveCfg = Debug|x64 {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|x64.Build.0 = Debug|x64 {93F066A5-A2D8-4926-A255-81077AEE5972}.Debug|x64.Deploy.0 = Debug|x64 diff --git a/src/Maestro/Maestro.Web/.config/settings.json b/src/Maestro/Maestro.Web/.config/settings.json index d2caf9d319..d08a81e560 100644 --- a/src/Maestro/Maestro.Web/.config/settings.json +++ b/src/Maestro/Maestro.Web/.config/settings.json @@ -18,5 +18,25 @@ "SignedOutCallbackPath": "/signout-callback-oidc" }, "GitDownloadLocation": "https://netcorenativeassets.blob.core.windows.net/resource-packages/external/windows/git/Git-2.32.0-64-bit.zip", - "EnableAutoBuildPromotion": "[config(FeatureManagement:AutoBuildPromotion)]" + "EnableAutoBuildPromotion": "[config(FeatureManagement:AutoBuildPromotion)]", + "DependencyFlowSLAs": { + "Repositories": { + "dotnet/arcade": { + "WarningUnconsumedBuildAge": 11, + "FailUnconsumedBuildAge": 14 + }, + "dotnet/source-build-externals": { + "WarningUnconsumedBuildAge": 11, + "FailUnconsumedBuildAge": 14 + }, + "dotnet/roslyn": { + "WarningUnconsumedBuildAge": 11, + "FailUnconsumedBuildAge": 14 + }, + "dotnet/extensions": { + "WarningUnconsumedBuildAge": 11, + "FailUnconsumedBuildAge": 14 + } + } + } } diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/GitHubInfo.cs b/src/Maestro/Maestro.Web/Pages/DependencyFlow/GitHubInfo.cs new file mode 100644 index 0000000000..f33c9af932 --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/GitHubInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Maestro.Web.Pages.DependencyFlow; + +public record GitHubInfo(string Owner, string Repo); diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml new file mode 100644 index 0000000000..834d31c431 --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml @@ -0,0 +1,119 @@ +@page "/DependencyFlow/incoming/{channelId}/{owner}/{repo}" +@using Maestro.Web.Pages.DependencyFlow +@model IncomingModel + +@{ + ViewBag.Title = "DependencyFlow"; +} + +@section Head { + +} + +For channel '@Model.ChannelName' based on @Model.GetGitHubInfo(Model.Build)?.Repo/@Model.Build?.GitHubBranch@@@Model.Build?.Commit.Substring(0, 6) build @Model.Build?.AzureDevOpsBuildNumber produced @Model.GetDateProduced(Model.Build) + +
+ @{ + var index = 0; + } + + @foreach (var incoming in Model.IncomingRepositories?.OrderBy(r => r.ShortName).ToArray() ?? Array.Empty()) + { + // We compute the "condition" of a dependency by first checking how old the build we have is. + // If it's older than we'd like, we then ALSO check the number of commits that we're missing + // If it's old but there are few commits, it's OK, there just hasn't been churn + // If it's old and there are lots of commits, ruh-roh! + + string conditionClass; + string textClass = "text-white"; + string linkClass = "link-light"; + string statusIcon = "✔️"; + + var elapsed = TimeSpan.Zero; + if (incoming.OldestPublishedButUnconsumedBuild != null) + { + elapsed = DateTime.UtcNow - incoming.OldestPublishedButUnconsumedBuild.DateProduced; + } + + if (incoming.OldestPublishedButUnconsumedBuild == null || elapsed.TotalDays < Model.SlaOptions.GetForRepo(incoming.ShortName).WarningUnconsumedBuildAge) + { + conditionClass = "bg-primary"; + } + else if (elapsed.TotalDays < Model.SlaOptions.GetForRepo(incoming.ShortName).FailUnconsumedBuildAge) + { + statusIcon = "⚠"; + conditionClass = "bg-warning"; + textClass = null; + linkClass = null; + } + else + { + statusIcon = "❌"; + conditionClass = "bg-danger"; + } + +
+
@incoming.ShortName
+
+
+ @statusIcon We are @(incoming.CommitDistance == null ? "(unknown)" : incoming.CommitDistance == 0 ? "0" : $"{incoming.CommitDistance}") commit(s) behind +
+

+ Oldest unconsumed - build: @(incoming.OldestPublishedButUnconsumedBuild == null ? "none" : incoming.OldestPublishedButUnconsumedBuild.DateProduced.Humanize()) / commit: @(incoming.CommitAge == null ? "(unknown)" : incoming.CommitAge.Humanize()) +

+
+ +
+ + index += 1; + if (index % 3 == 0) + { + // Wrap every 3 cards +
+ } + } +
+ +Rate Limit Remaining: @GetRateLimitInfo(Model.CurrentRateLimit) | @GetBuildInfo() + +@functions +{ + string DisplayFor(string repository) + { + return repository.Substring("https://github.com/".Length); + } + + string GetBuildInfo() + { + if (Program.SourceVersion == null) + { + return "Local build"; + } + + var branch = Program.SourceBranch ?? "unknown"; + + if (branch.StartsWith("refs/heads/")) + branch = branch.Substring("refs/heads/".Length); + + var sha = Program.SourceVersion?.Substring(0, 6); + return $"Built for branch {branch}@{sha}"; + } + + string GetRateLimitInfo(Octokit.RateLimit rateLimit) + { + return rateLimit == null + ? "unknown" + : $"{rateLimit.Remaining}(Resets {rateLimit.Reset.Humanize()})"; + } +} diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml.cs b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml.cs new file mode 100644 index 0000000000..71d3e70d3d --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Incoming.cshtml.cs @@ -0,0 +1,273 @@ +// 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 System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Humanizer; +using Maestro.Data; +using Maestro.Data.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.DotNet.GitHub.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Octokit; + +#nullable enable +namespace Maestro.Web.Pages.DependencyFlow; + +public class IncomingModel : PageModel +{ + private static readonly Regex _repoParser = new Regex(@"https?://(www\.)?github.com/(?[A-Za-z0-9-_\.]+)/(?[A-Za-z0-9-_\.]+)"); + + private readonly BuildAssetRegistryContext _context; + private readonly IGitHubClient _github; + private readonly ILogger _logger; + + public IncomingModel( + BuildAssetRegistryContext context, + IGitHubClientFactory gitHubClientFactory, + IOptions slaOptions, + ILogger logger) + { + _context = context; + // We'll only comparing public commits, so we don't need a token. + _github = gitHubClientFactory.CreateGitHubClient(""); + SlaOptions = slaOptions.Value; + _logger = logger; + } + + public SlaOptions SlaOptions { get; } + + public IReadOnlyList? IncomingRepositories { get; private set; } + public RateLimit? CurrentRateLimit { get; private set; } + + public Build? Build { get; private set; } + + public string? ChannelName { get; private set; } + + public async Task OnGet(int channelId, string owner, string repo) + { + var channel = await _context.Channels.FindAsync(channelId); + + if (channel == null) + { + return NotFound($"The channel with id '{channelId}' was not found."); + } + + ChannelName = channel.Name; + + var repoUrl = $"https://github.com/{owner}/{repo}"; + var latest = await _context.Builds + .Where(b => b.GitHubRepository == repoUrl) + .OrderByDescending(b => b.DateProduced) + .FirstOrDefaultAsync(); + + if (latest == null) + { + return NotFound($"No builds found for repository '{repoUrl}'."); + } + + var graphList = await _context.GetBuildGraphAsync(latest.Id); + var graph = graphList.ToDictionary(b => b.Id); + Build = graph[latest.Id]; + + if (Build == null) + { + return NotFound($"No builds found for repository '{repoUrl}' in channel '{channel.Name}'."); + } + + var incoming = new List(); + foreach (var dep in Build.DependentBuildIds) + { + var lastConsumedBuildOfDependency = graph[dep.BuildId]; + + if (lastConsumedBuildOfDependency == null) + { + _logger.LogWarning("Failed to find build with id '{BuildId}' in the graph", dep.BuildId); + continue; + } + + var gitHubInfo = GetGitHubInfo(lastConsumedBuildOfDependency); + + if (!IncludeRepo(gitHubInfo)) + { + continue; + } + + var (commitDistance, commitAge) = await GetCommitInfo(gitHubInfo, lastConsumedBuildOfDependency); + + var oldestPublishedButUnconsumedBuild = await GetOldestUnconsumedBuild(lastConsumedBuildOfDependency.Id); + + incoming.Add(new IncomingRepo( + lastConsumedBuildOfDependency, + gitHubInfo?.Repo ?? "", + oldestPublishedButUnconsumedBuild, + GetCommitUrl(lastConsumedBuildOfDependency), + GetBuildUrl(lastConsumedBuildOfDependency), + commitDistance, + commitAge)); + } + IncomingRepositories = incoming; + + CurrentRateLimit = _github.GetLastApiInfo().RateLimit; + + return Page(); + } + + private async Task GetOldestUnconsumedBuild(int lastConsumedBuildOfDependencyId) + { + // Note: We fetch `build` again here so that it will have channel information, which it doesn't when coming from the graph :( + var build = await _context.Builds.FindAsync(lastConsumedBuildOfDependencyId); + + if (build == null) + { + return null; + } + + var channelId = build.BuildChannels.FirstOrDefault(bc => bc.Channel.Classification == "product" || bc.Channel.Classification == "tools")?.ChannelId; + var publishedBuildsOfDependency = await _context.Builds + .Where(b => b.GitHubRepository == build.GitHubRepository && + b.DateProduced >= build.DateProduced.AddSeconds(-5) && + b.BuildChannels.Any(bc => bc.ChannelId == channelId)) + .ToListAsync(); + + var last = publishedBuildsOfDependency.LastOrDefault(); + if (last == null) + { + _logger.LogWarning("Last build didn't match last consumed build, treating dependency '{Dependency}' as up to date.", build.GitHubRepository); + return null; + } + + if (last.AzureDevOpsBuildId != build.AzureDevOpsBuildId) + { + _logger.LogWarning("Last build didn't match last consumed build."); + } + + return publishedBuildsOfDependency.Count > 1 + ? publishedBuildsOfDependency[^2] + : null; + } + + public GitHubInfo? GetGitHubInfo(Build? build) + { + GitHubInfo? gitHubInfo = null; + if (!string.IsNullOrEmpty(build?.GitHubRepository)) + { + var match = _repoParser.Match(build.GitHubRepository); + if (match.Success) + { + gitHubInfo = new GitHubInfo( + match.Groups["owner"].Value, + match.Groups["repo"].Value); + } + } + + return gitHubInfo; + } + + public string GetBuildUrl(Build? build) + => build == null + ? "(unknown)" + : $"https://dev.azure.com/{build.AzureDevOpsAccount}/{build.AzureDevOpsProject}/_build/results?buildId={build.AzureDevOpsBuildId}&view=results"; + + private bool IncludeRepo(GitHubInfo? gitHubInfo) + { + if (string.Equals(gitHubInfo?.Owner, "dotnet", StringComparison.OrdinalIgnoreCase) && + string.Equals(gitHubInfo?.Repo, "blazor", StringComparison.OrdinalIgnoreCase)) + { + // We don't want to track dependency staleness of the Blazor repo + // because it's not part of our process of automated dependency PRs. + return false; + } + + return true; + } + + public string GetCommitUrl(Build? build) + { + return build switch + { + null => "unknown", + _ => string.IsNullOrEmpty(build.GitHubRepository) + ? $"{build.AzureDevOpsRepository}/commits?itemPath=%2F&itemVersion=GC{build.Commit}" + : $"{build.GitHubRepository}/commits/{build.Commit}", + }; + } + + public string GetDateProduced(Build? build) + { + return build switch + { + null => "unknown", + _ => build.DateProduced.Humanize() + }; + } + + private async Task GetCommitsBehindAsync(GitHubInfo gitHubInfo, Build build) + { + try + { + var comparison = await _github.Repository.Commit.Compare(gitHubInfo.Owner, gitHubInfo.Repo, build.Commit, build.GitHubBranch); + + return comparison; + } + catch (NotFoundException) + { + _logger.LogWarning("Failed to compare commit history for '{0}/{1}' between '{2}' and '{3}'.", gitHubInfo.Owner, gitHubInfo.Repo, + build.Commit, build.GitHubBranch); + return null; + } + } + + private async Task<(int? commitDistance, DateTimeOffset? commitAge)> GetCommitInfo(GitHubInfo? gitHubInfo, Build lastConsumedBuild) + { + DateTimeOffset? commitAge = null; + int? commitDistance = null; + if (gitHubInfo != null) + { + var comparison = await GetCommitsBehindAsync(gitHubInfo, lastConsumedBuild); + + // We're using the branch as the "head" so "ahead by" is actually how far the branch (i.e. "master") is + // ahead of the commit. So it's also how far **behind** the commit is from the branch head. + commitDistance = comparison?.AheadBy; + + if (comparison != null && comparison.Commits.Count > 0) + { + // Follow the first parent starting at the last unconsumed commit until we find the commit directly after our current consumed commit + var nextCommit = comparison.Commits[^1]; + while (nextCommit.Parents[0].Sha != lastConsumedBuild.Commit) + { + var foundCommit = false; + foreach (var commit in comparison.Commits) + { + if (commit.Sha == nextCommit.Parents[0].Sha) + { + nextCommit = commit; + foundCommit = true; + break; + } + } + + if (foundCommit == false) + { + // Happens if there are over 250 commits + // We would need to use a paging API to follow commit history over 250 commits + _logger.LogDebug("Failed to follow commit parents and find correct commit age. Falling back to the date the build was produced"); + return (commitDistance, null); + } + } + + commitAge = nextCommit.Commit.Committer.Date; + } + } + return (commitDistance, commitAge); + } + + +} +#nullable restore diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/IncomingRepo.cs b/src/Maestro/Maestro.Web/Pages/DependencyFlow/IncomingRepo.cs new file mode 100644 index 0000000000..09ee642c23 --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/IncomingRepo.cs @@ -0,0 +1,18 @@ +// 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 Maestro.Data.Models; + +#nullable enable +namespace Maestro.Web.Pages.DependencyFlow; + +public record IncomingRepo( + Build LastConsumedBuild, + string ShortName, + Build? OldestPublishedButUnconsumedBuild, + string CommitUrl, + string BuildUrl, + int? CommitDistance, + DateTimeOffset? CommitAge); +#nullable restore diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/Sla.cs b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Sla.cs new file mode 100644 index 0000000000..cb12050a72 --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/Sla.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Maestro.Web.Pages.DependencyFlow; + +[DebuggerDisplay("{GetDebuggerDisplay(),nq}")] +public class Sla +{ + public int WarningUnconsumedBuildAge { get; set; } + public int FailUnconsumedBuildAge { get; set; } + + private string GetDebuggerDisplay() + => $"{nameof(Sla)}(Warn: {WarningUnconsumedBuildAge}, Fail: {FailUnconsumedBuildAge})"; +} diff --git a/src/Maestro/Maestro.Web/Pages/DependencyFlow/SlaOptions.cs b/src/Maestro/Maestro.Web/Pages/DependencyFlow/SlaOptions.cs new file mode 100644 index 0000000000..d86c445210 --- /dev/null +++ b/src/Maestro/Maestro.Web/Pages/DependencyFlow/SlaOptions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Maestro.Web.Pages.DependencyFlow; + +public class SlaOptions +{ + public IDictionary Repositories { get; } = + new Dictionary + { + ["[Default]"] = new() { FailUnconsumedBuildAge = 7, WarningUnconsumedBuildAge = 5 }, + }; + + public Sla GetForRepo(string repoShortName) + { + if (!Repositories.TryGetValue("dotnet/" + repoShortName, out var value)) + { + value = Repositories["[Default]"]; + } + + return value; + } +} diff --git a/src/Maestro/Maestro.Web/Pages/_Layout.cshtml b/src/Maestro/Maestro.Web/Pages/_Layout.cshtml index 79bfdefeb5..32674d47d9 100644 --- a/src/Maestro/Maestro.Web/Pages/_Layout.cshtml +++ b/src/Maestro/Maestro.Web/Pages/_Layout.cshtml @@ -73,6 +73,7 @@ display: table; } + @RenderSection("Head", required: false)