diff --git a/.github/workflows/nuget_slack_notifications.yml b/.github/workflows/nuget_slack_notifications.yml index 15e812c8fa..3603fa1dde 100644 --- a/.github/workflows/nuget_slack_notifications.yml +++ b/.github/workflows/nuget_slack_notifications.yml @@ -69,6 +69,7 @@ jobs: NEW_RELIC_HOST: staging-collector.newrelic.com NEW_RELIC_LICENSE_KEY: ${{ secrets.STAGING_LICENSE_KEY }} DOTTY_LAST_RUN_TIMESTAMP: ${{ env.LAST_RUN_TIMESTAMP }} + DOTTY_SEARCH_ROOT_PATH: ${{ github.workspace }} run: | if [ ${{ inputs.daysToSearch }} != "" ]; then diff --git a/.github/workflows/scripts/nugetSlackNotifications/CsprojHandler.cs b/.github/workflows/scripts/nugetSlackNotifications/CsprojHandler.cs new file mode 100644 index 0000000000..cb907621ab --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/CsprojHandler.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml.Serialization; +using Serilog; + +namespace nugetSlackNotifications +{ + public class CsprojHandler + { + public static async Task> UpdatePackageReferences(string csprojPath, List versionDatas) + { + var updateLog = new List(); + var csprojLines = await File.ReadAllLinesAsync(csprojPath); + + var packages = Parse(csprojLines); + if (packages.Count == 0) + { + Log.Warning("No packages found in csproj file " + csprojPath); + return updateLog; + } + + foreach (var versionData in versionDatas) + { + var matchingPackages = packages.Where(p => p.Include == versionData.PackageName).ToList(); + if (matchingPackages.Count == 0) + { + Log.Warning($"No matching packages found in csproj file for {versionData.PackageName}"); + continue; + } + + foreach (var package in matchingPackages) + { + if(package.VersionAsVersion < versionData.NewVersionAsVersion && package.Pin) + { + Log.Warning($"Not updating {package.Include} for {package.TargetFramework}, it is pinned to {package.Version}. Manual verification recommended."); + continue; + } + + if (package.VersionAsVersion < versionData.NewVersionAsVersion) + { + Log.Information($"Updating {package.Include} from {package.Version} to {versionData.NewVersion}"); + var pattern = @"\d+(\.\d+){2,3}"; + var result = Regex.Replace(csprojLines[package.LineNumber], pattern, versionData.NewVersion); + csprojLines[package.LineNumber] = result; + + updateLog.Add($"- Package [{versionData.PackageName}]({versionData.Url}) " + + $"for {package.TargetFramework} " + + $"was updated from {versionData.OldVersion} to {versionData.NewVersion} " + + $"on {versionData.PublishDate.ToShortDateString()}."); + } + } + } + + await File.WriteAllLinesAsync(csprojPath, csprojLines); + updateLog.Add(""); + return updateLog; + } + + private static List Parse(string[] csprojLines) + { + var packages = new List(); + try + { + for (int i = 0; i < csprojLines.Length; i++) + { + var line = csprojLines[i]; + if (!line.Contains("PackageReference")) + { + continue; + } + + var serializer = new XmlSerializer(typeof(PackageReference)); + using (var reader = new StringReader(line)) + { + var packageReference = (PackageReference)serializer.Deserialize(reader); + packageReference.LineNumber = i; + packages.Add(packageReference); + } + } + + return packages; + } + catch (Exception e) + { + Log.Error(e, "XML issue"); + return packages; + } + } + } +} diff --git a/.github/workflows/scripts/nugetSlackNotifications/NugetVersionData.cs b/.github/workflows/scripts/nugetSlackNotifications/NugetVersionData.cs new file mode 100644 index 0000000000..1ac0928723 --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/NugetVersionData.cs @@ -0,0 +1,24 @@ +using System; + +namespace nugetSlackNotifications +{ + public class NugetVersionData + { + public string PackageName { get; set; } + public string OldVersion { get; set; } + public string NewVersion { get; set; } + public Version NewVersionAsVersion { get; set; } + public string Url { get; set; } + public DateTime PublishDate { get; set; } + + public NugetVersionData(string packageName, string oldVersion, string newVersion, string url, DateTime publishDate) + { + PackageName = packageName; + OldVersion = oldVersion; + NewVersion = newVersion; + NewVersionAsVersion = new Version(newVersion); + Url = url; + PublishDate = publishDate; + } + } +} diff --git a/.github/workflows/scripts/nugetSlackNotifications/PackageInfo.cs b/.github/workflows/scripts/nugetSlackNotifications/PackageInfo.cs new file mode 100644 index 0000000000..56f2509b89 --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/PackageInfo.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace nugetSlackNotifications +{ + public class PackageInfo + { + [JsonPropertyName("packageName")] + public string PackageName { get; set; } + [JsonPropertyName("ignorePatch")] + public bool IgnorePatch { get; set; } + [JsonPropertyName("ignoreMinor")] + public bool IgnoreMinor { get; set; } + [JsonPropertyName("ignoreReason")] + public string IgnoreReason {get; set;} + } +} diff --git a/.github/workflows/scripts/nugetSlackNotifications/PackageReference.cs b/.github/workflows/scripts/nugetSlackNotifications/PackageReference.cs new file mode 100644 index 0000000000..06235055f8 --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/PackageReference.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.RegularExpressions; +using System.Xml.Serialization; + +namespace nugetSlackNotifications +{ + public class PackageReference + { + [XmlAttribute] + public string Include { get; set; } + + [XmlAttribute] + public string Version { get; set; } + + [XmlIgnore] + public Version VersionAsVersion => new Version(Version); + + [XmlIgnore] + public int LineNumber { get; set; } + + [XmlAttribute] + public string Condition { get; set; } + + public string TargetFramework + { + get + { + if (Condition == null) + { + return null; + } + var match = Regex.Match(Condition, @"net\d+\.*\d+"); + return match.Success ? match.Value : null; + } + } + + [XmlAttribute] + public bool Pin { get; set; } + } +} diff --git a/.github/workflows/scripts/nugetSlackNotifications/Program.cs b/.github/workflows/scripts/nugetSlackNotifications/Program.cs index 88243a5bc0..f11272d1ea 100644 --- a/.github/workflows/scripts/nugetSlackNotifications/Program.cs +++ b/.github/workflows/scripts/nugetSlackNotifications/Program.cs @@ -8,13 +8,13 @@ using System.Net.Http; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; using NuGet.Common; using NuGet.Configuration; using NuGet.Protocol; using NuGet.Protocol.Core.Types; using Repository = NuGet.Protocol.Core.Types.Repository; +using System.IO; namespace nugetSlackNotifications { @@ -28,8 +28,11 @@ public class Program private static readonly string _webhook = Environment.GetEnvironmentVariable("DOTTY_WEBHOOK"); private static readonly string _githubToken = Environment.GetEnvironmentVariable("DOTTY_TOKEN"); private static readonly DateTimeOffset _lastRunTimestamp = DateTimeOffset.TryParse(Environment.GetEnvironmentVariable("DOTTY_LAST_RUN_TIMESTAMP"), out var timestamp) ? timestamp : DateTimeOffset.MinValue; + private static readonly string _searchRootPath = Environment.GetEnvironmentVariable("DOTTY_SEARCH_ROOT_PATH") ?? "."; private const string PackageInfoFilename = "packageInfo.json"; - + private const string ProjectsJsonFilename = "projectInfo.json"; + private const string Owner = "newrelic"; + private const string Repo = "newrelic-dotnet-agent"; static async Task Main() { @@ -48,15 +51,14 @@ static async Task Main() var metadataResource = await sourceRepository.GetResourceAsync(); var sourceCacheContext = new SourceCacheContext(); - if (!System.IO.File.Exists(PackageInfoFilename)) + if (!File.Exists(PackageInfoFilename)) { Log.Error($"{PackageInfoFilename} not found in the current directory. Exiting."); return; } - var packageInfoJson = await System.IO.File.ReadAllTextAsync(PackageInfoFilename); + var packageInfoJson = await File.ReadAllTextAsync(PackageInfoFilename); var packageInfos = JsonSerializer.Deserialize(packageInfoJson); - foreach (var package in packageInfos) { try @@ -70,12 +72,44 @@ static async Task Main() } } - await AlertOnNewVersions(); - await CreateGithubIssuesForNewVersions(); + if (!File.Exists(ProjectsJsonFilename)) + { + Log.Error($"{ProjectsJsonFilename} not found in the current directory. Exiting."); + return; + } + + var updateLog = new List(); + var projectInfoJson = await File.ReadAllTextAsync(ProjectsJsonFilename); + var projectInfos = JsonSerializer.Deserialize(projectInfoJson); + foreach (var projectInfo in projectInfos) + { + var projectFile = Path.Combine(_searchRootPath, projectInfo.ProjectFile); + if (!File.Exists(projectFile)) + { + Log.Warning($"Could not find {projectFile}, make sure projectFile path is relative."); + continue; + } + + var projectLog = await CsprojHandler.UpdatePackageReferences(projectFile, _newVersions); + if (projectLog.Count > 0) + { + updateLog.Add($"**{projectInfo.ProjectFile}**"); + updateLog.AddRange(projectLog); + } + + } + + var prUrl = await CreateGithubPullRequestForNewVersions(projectInfos, string.Join('\n', updateLog)); + await AlertOnNewVersions(prUrl); + + // Currently don'y want to create issues, but may in the future + // If/When we do, this shuold be moved above the PR creation so we can link issues to the PR + //await CreateGithubIssuesForNewVersions(); } [Transaction] - static async Task CheckPackage(PackageInfo package, PackageMetadataResource metadataResource, SourceCacheContext sourceCacheContext, DateTimeOffset searchTime) + static async Task CheckPackage(PackageInfo package, PackageMetadataResource metadataResource, + SourceCacheContext sourceCacheContext, DateTimeOffset searchTime) { var packageName = package.PackageName; @@ -106,7 +140,7 @@ static async Task CheckPackage(PackageInfo package, PackageMetadataResource meta { if (previousVersion.Major == latestVersion.Major && previousVersion.Minor == latestVersion.Minor) { - Log.Information($"Package {packageName} ignores Patch version updates; the Minor version ({latestVersion.Major}.{latestVersion.Minor:2}) has not been updated."); + Log.Information($"Package {packageName} ignores Patch version updates; the Minor version ({latestVersion.Major}.{latestVersion.Minor}) has not been updated."); return; } } @@ -134,7 +168,7 @@ static async Task CheckPackage(PackageInfo package, PackageMetadataResource meta } [Transaction] - static async Task AlertOnNewVersions() + static async Task AlertOnNewVersions(string prUrl) { if (_newVersions.Count > 0 && _webhook != null && !_testMode) // only message channel if there's package updates to report AND we have a webhook from the environment AND we're not in test mode @@ -144,6 +178,10 @@ static async Task AlertOnNewVersions() { msg += $"\n\t:package: {versionData.PackageName} {versionData.OldVersion} :point_right: <{versionData.Url}|{versionData.NewVersion}>"; } + + msg += $"\n\nI did the work so you won't have to!"; + msg += $"\n" + prUrl + "\n"; + msg += $"\nThanks and have a wonderful {DateTime.Now.DayOfWeek}."; await SendSlackNotification(msg); @@ -171,7 +209,7 @@ static async Task CreateGithubIssuesForNewVersions() }; newIssue.Labels.Add("testing"); newIssue.Labels.Add("Core Technologies"); - var issue = await ghClient.Issue.Create("newrelic", "newrelic-dotnet-agent", newIssue); + var issue = await ghClient.Issue.Create(Owner, Repo, newIssue); Log.Information($"Created issue #{issue.Id} for {versionData.PackageName} update to {versionData.NewVersion} in newrelic/newrelic-dotnet-agent."); } } @@ -181,6 +219,55 @@ static async Task CreateGithubIssuesForNewVersions() } } + [Transaction] + static async Task CreateGithubPullRequestForNewVersions(IEnumerable projectInfos, string updateLog) + { + + if (_newVersions.Count > 0 && _githubToken != null && !_testMode) // only message channel if there's package updates to report AND we have a GH token from the environment AND we're not in test mode + { + var ghClient = new GitHubClient(new ProductHeaderValue("Dotty-Robot")); + var tokenAuth = new Credentials(_githubToken); + ghClient.Credentials = tokenAuth; + + var masterReference = await ghClient.Git.Reference.Get(Owner, Repo, "heads/main"); + var branchName = $"dotty/test-updates-{DateTime.Now.ToString("yyyy-MMM-dd")}"; + var newBranch = new NewReference($"refs/heads/{branchName}", masterReference.Object.Sha); + await ghClient.Git.Reference.Create(Owner, Repo, newBranch); + var latestCommit = await ghClient.Git.Commit.Get(Owner, Repo, masterReference.Object.Sha); + var nt = new NewTree { BaseTree = latestCommit.Tree.Sha }; + foreach (var projectInfo in projectInfos) + { + // string.Join with \n seems to allow github to see the changed lines and not the entire file as "changed" + nt.Tree.Add(new NewTreeItem + { + Path = projectInfo.ProjectFile, + Mode = "100644", + Type = TreeType.Blob, + Content = string.Join('\n', await File.ReadAllLinesAsync(Path.Combine(_searchRootPath, projectInfo.ProjectFile))) + }); + } + + var newTree = await ghClient.Git.Tree.Create(Owner, Repo, nt); + var commitMessage = "test:Dotty instrumentation library updates for " + DateTime.Now.ToString("yyyy-MMM-dd"); + var newCommit = new NewCommit(commitMessage, newTree.Sha, masterReference.Object.Sha); + var commit = await ghClient.Git.Commit.Create(Owner, Repo, newCommit); + var branchref = await ghClient.Git.Reference.Update(Owner, Repo, $"heads/{branchName}", new ReferenceUpdate(commit.Sha)); + Log.Information($"Successfully created {branchName} branch."); + + var newPr = new NewPullRequest(commitMessage, branchName, "main"); + newPr.Body = "Dotty updated the following for your convenience.\n\n" + updateLog; + var pullRequest = await ghClient.PullRequest.Create(Owner, Repo, newPr); + Log.Information($"Successfully created PR for {branchName} at {pullRequest.HtmlUrl}"); + + return pullRequest.HtmlUrl; + } + else + { + Log.Information($"Pull request will not be created: # of new versions={_newVersions.Count}, token available={_webhook != null}, test mode={_testMode}"); + return ""; + } + } + [Trace] static async Task SendSlackNotification(string msg) { @@ -212,34 +299,4 @@ static async Task SendSlackNotification(string msg) } } } - - public class NugetVersionData - { - public string PackageName { get; set; } - public string OldVersion { get; set; } - public string NewVersion { get; set; } - public string Url { get; set; } - public DateTime PublishDate { get; set; } - - public NugetVersionData(string packageName, string oldVersion, string newVersion, string url, DateTime publishDate) - { - PackageName = packageName; - OldVersion = oldVersion; - NewVersion = newVersion; - Url = url; - PublishDate = publishDate; - } - } - - public class PackageInfo - { - [JsonPropertyName("packageName")] - public string PackageName { get; set; } - [JsonPropertyName("ignorePatch")] - public bool IgnorePatch { get; set; } - [JsonPropertyName("ignoreMinor")] - public bool IgnoreMinor { get; set; } - [JsonPropertyName("ignoreReason")] - public string IgnoreReason {get; set;} - } } diff --git a/.github/workflows/scripts/nugetSlackNotifications/ProjectInfo.cs b/.github/workflows/scripts/nugetSlackNotifications/ProjectInfo.cs new file mode 100644 index 0000000000..ffbc7ea8ff --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/ProjectInfo.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace nugetSlackNotifications +{ + public class ProjectInfo + { + [JsonPropertyName("projectFile")] + public string ProjectFile { get; set; } + } +} diff --git a/.github/workflows/scripts/nugetSlackNotifications/nugetSlackNotifications.csproj b/.github/workflows/scripts/nugetSlackNotifications/nugetSlackNotifications.csproj index ba3a9c3ed1..4411b0d649 100644 --- a/.github/workflows/scripts/nugetSlackNotifications/nugetSlackNotifications.csproj +++ b/.github/workflows/scripts/nugetSlackNotifications/nugetSlackNotifications.csproj @@ -1,4 +1,4 @@ - + Exe @@ -20,6 +20,9 @@ PreserveNewest + + PreserveNewest + diff --git a/.github/workflows/scripts/nugetSlackNotifications/projectInfo.json b/.github/workflows/scripts/nugetSlackNotifications/projectInfo.json new file mode 100644 index 0000000000..3cfb1def2c --- /dev/null +++ b/.github/workflows/scripts/nugetSlackNotifications/projectInfo.json @@ -0,0 +1,5 @@ +[ + { + "projectFile": "tests/Agent/IntegrationTests/SharedApplications/Common/MFALatestPackages/MFALatestPackages.csproj" + } +]